feat: Rework ResourceStore to return extra info

* feat: change return types in ResourceStore.ts

* feat: change return types in BaseResourceStore.ts

* feat: change return types in LockingResourceStore.ts

* feat: change return types in RoutingResourceStore.ts

* feat: change return types in MonitoringStore.ts

* feat: change return types in PassthroughStore.ts

* feat: change return types in ReadOnlyStore.ts

* feat: change return types in PatchHandler.ts

* feat: change return types in PatchingStore.ts

* feat: change return types in RepresentationPatchHandler.ts

* feat: create createResourceIdentifier() function for convenience

* feat: adapt PostOperationHandler.ts to new typing

* feat: change return types in RepresentationConvertingStore.ts

* feat: adapt DataAccessorBasedStore.ts implementation to new typings

* feat: adapt UnsecureWebSocketsProtocol.ts to new typing

* chore: add temporary comments

* fix: return correct Location header on POST request with slug

* fix: npm run lint command needs more packages

* fix: linting errors

* chore: revert ed9952b

* test: adapt PostOperationHandler tests

* test: adapt UnsecureWebSocketsProtocol tests

* test: adapt DataAccessorBasedStore tests

* fix: linting errors

* feat: emit specific created, deleted, updated events in MonitoringStore

* test: adapt RepresentationPatchHandler tests

* fix: revert UnsecureWebSocketsProtocol changes

* feat: emit extra parameter on changed

* test: adapt MonitoringStore tests

* fix: linting errors

* test: add test to MonitorStore.test for coverage

* fix: linting error

* chore: update doc in ResourceStore.ts

* test: improve MonitoringStore tests

* chore: update RELEASE_NOTES.md

* chore: add extra info about the MonitoringStore to documentation/resource-store.md

* chore: Update RELEASE_NOTES.md

Co-authored-by: Anton Wiklund <ixuz07@gmail.com>

* chore: Update documentation/resource-store.md

Co-authored-by: Anton Wiklund <ixuz07@gmail.com>

* chore: very small changes

* chore: simplify metadata creation

* fix: DataAccessorBasedStore improvement and bugfix

* chore: improve resource-store.md

* chore: adapt MonitoringStore event names, update docs and apply code suggestion

* chore: use ResourceStoreResponse type

* fix: typo

* chore: rename ResourceStoreResponse type to ChangeMap

* chore: adapt .gitignore to name change

Co-authored-by: Anton Wiklund <ixuz07@gmail.com>
This commit is contained in:
Arthur Joppart
2022-07-06 14:40:28 +02:00
committed by GitHub
parent 1c65b06392
commit e0954cf2a7
22 changed files with 468 additions and 217 deletions

View File

@@ -3,12 +3,11 @@ import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import type { ResourceStore, ChangeMap } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
import { AS, SOLID_AS, SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => {
let operation: Operation;
@@ -21,7 +20,16 @@ describe('A PostOperationHandler', (): void => {
body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
store = {
addResource: jest.fn(async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier)),
addResource: jest.fn(async(): Promise<ChangeMap> => ({
'https://example.com/parent/newPath': new RepresentationMetadata(
{ path: 'https://example.com/parent/newPath' },
{ [SOLID_AS.terms.Activity.value]: AS.Create },
),
'https://example.com/parent/': new RepresentationMetadata(
{ path: 'https://example.come/parent/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
})),
} as unknown as ResourceStore;
handler = new PostOperationHandler(store);
});
@@ -41,7 +49,7 @@ describe('A PostOperationHandler', (): void => {
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(201);
expect(result.metadata).toBeInstanceOf(RepresentationMetadata);
expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('newPath');
expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('https://example.com/parent/newPath');
expect(result.data).toBeUndefined();
expect(store.addResource).toHaveBeenCalledTimes(1);
expect(store.addResource).toHaveBeenLastCalledWith(operation.target, body, conditions);

View File

@@ -23,7 +23,7 @@ import type { Guarded } from '../../../src/util/GuardedStream';
import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy';
import { trimTrailingSlashes } from '../../../src/util/PathUtil';
import { guardedStreamFrom } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC } from '../../../src/util/Vocabularies';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC, SOLID_AS, AS } from '../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
const GENERATED_PREDICATE = namedNode('generated');
@@ -265,11 +265,22 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
});
await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[result.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(Object.keys(result)).toEqual([
root,
expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
]);
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
const generatedID = Object.keys(result).find((key): boolean => key !== root)!;
expect(generatedID).toBeDefined();
expect(generatedID).toMatch(new RegExp(`^${root}[^/]+$`, 'u'));
await expect(arrayifyStream(accessor.data[generatedID].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[generatedID]).toBeTruthy();
expect(accessor.data[generatedID].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(result[generatedID].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
});
it('can write containers.', async(): Promise<void> => {
@@ -278,16 +289,25 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ '<> a <http://test.com/coolContainer>.' ]);
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
});
expect(accessor.data[result.path]).toBeTruthy();
expect(accessor.data[result.path].metadata.contentType).toBeUndefined();
const { data, metadata } = await store.getRepresentation(result);
expect(Object.keys(result)).toEqual([
root,
expect.stringMatching(new RegExp(`^${root}[^/]+?/$`, 'u')),
]);
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
const generatedID = Object.keys(result).find((key): boolean => key !== root)!;
expect(generatedID).toBeDefined();
expect(generatedID).toMatch(new RegExp(`^${root}[^/]+?/$`, 'u'));
expect(accessor.data[generatedID]).toBeTruthy();
expect(accessor.data[generatedID].metadata.contentType).toBeUndefined();
expect(result[generatedID].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
const { data, metadata } = await store.getRepresentation({ path: generatedID });
const quads = await arrayifyStream<Quad>(data);
expect(metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(quads.some((entry): boolean => entry.subject.value === result.path &&
expect(quads.some((entry): boolean => entry.subject.value === generatedID &&
entry.object.value === 'http://test.com/coolContainer')).toBeTruthy();
});
@@ -295,10 +315,14 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, 'newName');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: `${root}newName`,
const result = store.addResource(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[`${root}newName`]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[`${root}newName`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
});
it('errors on a slug ending on / without Link rel:type Container header.', async(): Promise<void> => {
@@ -320,10 +344,14 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(RDF.terms.type, LDP.terms.Container);
representation.metadata.add(SOLID_HTTP.terms.slug, 'newContainer');
representation.data = guardedStreamFrom([ `` ]);
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: `${root}newContainer/`,
[root]: expect.any(RepresentationMetadata),
[`${root}newContainer/`]: expect.any(RepresentationMetadata),
});
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect(result[`${root}newContainer/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
});
it('generates a new URI if adding the slug would create an existing URI.', async(): Promise<void> => {
@@ -331,20 +359,26 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(SOLID_HTTP.terms.slug, 'newName');
accessor.data[`${root}newName`] = representation;
const result = await store.addResource(resourceID, representation);
expect(result).not.toEqual({
path: `${root}newName`,
});
expect(result).not.toEqual({
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
});
expect(result).not.toEqual(expect.objectContaining({
[`${root}newName`]: expect.any(RepresentationMetadata),
}));
expect(result).not.toEqual(expect.objectContaining({
[expect.any(String)]: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
}));
});
it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, '&%26');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({ path: `${root}%26%26` });
const result = store.addResource(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[`${root}%26%26`]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[`${root}%26%26`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
});
it('errors if the slug contains a slash.', async(): Promise<void> => {
@@ -408,8 +442,11 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves
.toEqual([{ path: `${root}` }]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(mock).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenLastCalledWith(resourceID);
@@ -438,10 +475,13 @@ describe('A DataAccessorBasedStore', (): void => {
it('can write resources.', async(): Promise<void> => {
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@@ -455,10 +495,13 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}container/` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@@ -468,10 +511,13 @@ describe('A DataAccessorBasedStore', (): void => {
it('can overwrite resources which does not update parent metadata.', async(): Promise<void> => {
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@@ -479,9 +525,11 @@ describe('A DataAccessorBasedStore', (): void => {
// Parent metadata does not get updated if the resource already exists
representation = new BasicRepresentation('updatedText', 'text/plain');
mockDate.mockReturnValue(later);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}resource` },
]);
const result2 = store.setRepresentation(resourceID, representation);
await expect(result2).resolves.toEqual({
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result2)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ 'updatedText' ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(later.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@@ -492,10 +540,13 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` };
representation.metadata.add(namedNode('notGen'), 'value');
representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(namedNode('notGen'))?.value).toBe('value');
expect(accessor.data[resourceID.path].metadata.get(namedNode('gen'))).toBeUndefined();
@@ -505,10 +556,13 @@ describe('A DataAccessorBasedStore', (): void => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete accessor.data[root];
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}` },
{ path: `${root}resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
});
@@ -521,10 +575,13 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom(
[ quad(namedNode(`${root}resource/`), namedNode('a'), namedNode('coolContainer')) ],
);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}` },
{ path: `${root}container/` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
});
@@ -544,11 +601,17 @@ describe('A DataAccessorBasedStore', (): void => {
it('creates recursive containers when needed.', async(): Promise<void> => {
const resourceID = { path: `${root}a/b/resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}a/` },
{ path: `${root}a/b/` },
{ path: `${root}a/b/resource` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[`${root}`]: expect.any(RepresentationMetadata),
[`${root}a/`]: expect.any(RepresentationMetadata),
[`${root}a/b/`]: expect.any(RepresentationMetadata),
[`${root}a/b/resource`]: expect.any(RepresentationMetadata),
});
expect((await result)[`${root}`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[`${root}a/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect((await result)[`${root}a/b/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect((await result)[`${root}a/b/resource`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[`${root}a/`].metadata.getAll(RDF.terms.type).map((type): string => type.value))
.toContain(LDP.Container);
@@ -573,9 +636,11 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}` },
]);
const result = store.setRepresentation(resourceID, representation);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
});
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create);
expect(accessor.data[resourceID.path]).toBeTruthy();
expect(Object.keys(accessor.data)).toHaveLength(1);
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
@@ -658,51 +723,70 @@ describe('A DataAccessorBasedStore', (): void => {
});
it('will delete resources.', async(): Promise<void> => {
accessor.data[`${root}resource`] = representation;
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
{ path: root },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
const resourceID = { path: `${root}resource` };
accessor.data[resourceID.path] = representation;
await expect(store.deleteResource(resourceID)).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(GENERATED_PREDICATE)).toBeUndefined();
});
it('will delete root non-storage containers.', async(): Promise<void> => {
accessor.data[root] = new BasicRepresentation(representation.data, containerMetadata);
await expect(store.deleteResource({ path: root })).resolves.toEqual([
{ path: root },
]);
await expect(store.deleteResource({ path: root })).resolves.toEqual(
{ [root]: expect.any(RepresentationMetadata) },
);
expect(accessor.data[root]).toBeUndefined();
});
it('will delete a root storage auxiliary resource of a non-root container.', async(): Promise<void> => {
const resourceID = { path: `${root}container/` };
const auxResourceID = { path: `${root}container/.dummy` };
const storageMetadata = new RepresentationMetadata(representation.metadata);
accessor.data[`${root}container/`] = new BasicRepresentation(representation.data, storageMetadata);
accessor.data[`${root}container/.dummy`] = representation;
accessor.data[resourceID.path] = new BasicRepresentation(representation.data, storageMetadata);
accessor.data[auxResourceID.path] = representation;
auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true);
await expect(store.deleteResource({ path: `${root}container/.dummy` })).resolves.toEqual([
{ path: `${root}container/.dummy` },
{ path: `${root}container/` },
]);
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined();
const result = store.deleteResource(auxResourceID);
await expect(result).resolves.toEqual(
expect.objectContaining({
[resourceID.path]: expect.any(RepresentationMetadata),
[auxResourceID.path]: expect.any(RepresentationMetadata),
}),
);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[auxResourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete);
expect(accessor.data[auxResourceID.path]).toBeUndefined();
});
it('will delete related auxiliary resources.', async(): Promise<void> => {
accessor.data[`${root}container/`] = representation;
accessor.data[`${root}container/.dummy`] = representation;
await expect(store.deleteResource({ path: `${root}container/` })).resolves.toEqual([
{ path: `${root}container/` },
{ path: `${root}container/.dummy` },
{ path: root },
]);
expect(accessor.data[`${root}container/`]).toBeUndefined();
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined();
const resourceID = { path: `${root}container/` };
const auxResourceID = { path: `${root}container/.dummy` };
accessor.data[resourceID.path] = representation;
accessor.data[auxResourceID.path] = representation;
const result = store.deleteResource(resourceID);
await expect(result).resolves.toEqual(
expect.objectContaining({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
[auxResourceID.path]: expect.any(RepresentationMetadata),
}),
);
expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete);
expect((await result)[auxResourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeUndefined();
});
it('will still delete a resource if deleting auxiliary resources causes errors.', async(): Promise<void> => {
accessor.data[`${root}resource`] = representation;
accessor.data[`${root}resource.dummy`] = representation;
const resourceID = { path: `${root}resource` };
const auxResourceID = { path: `${root}resource.dummy` };
accessor.data[resourceID.path] = representation;
accessor.data[auxResourceID.path] = representation;
const deleteFn = accessor.deleteResource;
accessor.deleteResource = jest.fn(async(identifier: ResourceIdentifier): Promise<void> => {
if (auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
@@ -712,12 +796,14 @@ describe('A DataAccessorBasedStore', (): void => {
});
const { logger } = store as any;
logger.error = jest.fn();
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
{ path: root },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
expect(accessor.data[`${root}resource.dummy`]).toBeDefined();
const result = store.deleteResource(resourceID);
expect(Object.keys(await result)).toHaveLength(2);
await expect(result).resolves.toEqual({
[root]: expect.any(RepresentationMetadata),
[resourceID.path]: expect.any(RepresentationMetadata),
});
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeDefined();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith(
'Error deleting auxiliary resource http://test.com/resource.dummy: auxiliary error!',

View File

@@ -1,33 +1,86 @@
import type { Patch } from '../../../src/http/representation/Patch';
import type { Representation } from '../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import { MonitoringStore } from '../../../src/storage/MonitoringStore';
import type { ResourceStore } from '../../../src/storage/ResourceStore';
import { AS, SOLID_AS } from '../../../src/util/Vocabularies';
describe('A MonitoringStore', (): void => {
let store: MonitoringStore;
let source: ResourceStore;
let changedCallback: () => void;
const modified = [
{ path: 'http://example.org/modified/1' },
{ path: 'http://example.org/modified/2' },
];
let createdCallback: () => void;
let updatedCallback: () => void;
let deletedCallback: () => void;
const addResourceReturnMock = {
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Create },
),
'http://example.org/foo/bar/': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
const setRepresentationReturnMock = {
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
const deleteResourceReturnMock = {
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Delete },
),
'http://example.org/foo/bar/': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
const modifyResourceReturnMock = {
'http://example.org/foo/bar/old': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Delete },
),
'http://example.org/foo/bar/new': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/new' },
{ [SOLID_AS.terms.Activity.value]: AS.Create },
),
'http://example.org/foo/bar/': new RepresentationMetadata(
{ path: 'http://example.org/foo/bar/' },
{ [SOLID_AS.terms.Activity.value]: AS.Update },
),
};
beforeEach(async(): Promise<void> => {
source = {
getRepresentation: jest.fn(async(): Promise<any> => ({ success: true })),
addResource: jest.fn(async(): Promise<any> => ({ path: 'http://example.org/foo/bar/new' })),
setRepresentation: jest.fn(async(): Promise<any> => modified),
deleteResource: jest.fn(async(): Promise<any> => modified),
modifyResource: jest.fn(async(): Promise<any> => modified),
addResource: jest.fn(async(): Promise<any> => addResourceReturnMock),
setRepresentation: jest.fn(async(): Promise<any> => setRepresentationReturnMock),
deleteResource: jest.fn(async(): Promise<any> => deleteResourceReturnMock),
modifyResource: jest.fn(async(): Promise<any> => modifyResourceReturnMock),
hasResource: jest.fn(async(): Promise<any> => undefined),
};
store = new MonitoringStore(source);
changedCallback = jest.fn();
createdCallback = jest.fn();
updatedCallback = jest.fn();
deletedCallback = jest.fn();
store.on('changed', changedCallback);
store.on(AS.Create, createdCallback);
store.on(AS.Update, updatedCallback);
store.on(AS.Delete, deletedCallback);
});
afterEach(async(): Promise<void> => {
store.removeListener('changed', changedCallback);
store.removeListener(AS.Create, createdCallback);
store.removeListener(AS.Update, updatedCallback);
store.removeListener(AS.Delete, deletedCallback);
});
it('calls getRepresentation directly from the source.', async(): Promise<void> => {
@@ -43,67 +96,89 @@ describe('A MonitoringStore', (): void => {
});
it('calls addResource directly from the source.', async(): Promise<void> => {
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves
.toStrictEqual({ path: 'http://example.org/foo/bar/new' });
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves.toEqual({
'http://example.org/foo/bar/new': expect.any(RepresentationMetadata),
'http://example.org/foo/bar/': expect.any(RepresentationMetadata),
});
expect(source.addResource).toHaveBeenCalledTimes(1);
expect(source.addResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
});
it('fires container and resource change events after addResource.', async(): Promise<void> => {
it('fires appropriate events according to the return value of source.addResource.', async(): Promise<void> => {
const result = store.addResource({ path: 'http://example.org/foo/bar/' }, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create);
expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(deletedCallback).toHaveBeenCalledTimes(0);
});
it('calls setRepresentation directly from the source.', async(): Promise<void> => {
await expect(store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation))
.resolves.toEqual(modified);
.resolves.toEqual(setRepresentationReturnMock);
expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
});
it('fires all modified change events after setRepresentation.', async(): Promise<void> => {
it('fires appropriate events according to the return value of source.setRepresentation.', async(): Promise<void> => {
const result = store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' });
expect(changedCallback).toHaveBeenCalledTimes(1);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Update);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(deletedCallback).toHaveBeenCalledTimes(0);
});
it('calls deleteResource directly from the source.', async(): Promise<void> => {
await expect(store.deleteResource({ path: 'http://example.org/foo/bar' }))
.resolves.toEqual(modified);
.resolves.toEqual(deleteResourceReturnMock);
expect(source.deleteResource).toHaveBeenCalledTimes(1);
expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, undefined);
});
it('fires all modified change events after deleteResource.', async(): Promise<void> => {
it('fires appropriate events according to the return value of source.deleteResource.', async(): Promise<void> => {
const result = store.deleteResource({ path: 'http://example.org/foo/bar' });
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Delete);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(deletedCallback).toHaveBeenCalledTimes(1);
expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
});
it('calls modifyResource directly from the source.', async(): Promise<void> => {
await expect(store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch))
.resolves.toEqual(modified);
.resolves.toEqual(modifyResourceReturnMock);
expect(source.modifyResource).toHaveBeenCalledTimes(1);
expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
});
it('fires all modified change events after modifyResource.', async(): Promise<void> => {
it('fires appropriate events according to the return value of source.modifyResource.', async(): Promise<void> => {
const result = store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' });
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' });
expect(changedCallback).toHaveBeenCalledTimes(3);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }, AS.Delete);
expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(deletedCallback).toHaveBeenCalledTimes(1);
expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' });
});
it('calls hasResource directly from the source.', async(): Promise<void> => {
@@ -111,4 +186,20 @@ describe('A MonitoringStore', (): void => {
expect(source.hasResource).toHaveBeenCalledTimes(1);
expect(source.hasResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' });
});
it('should not emit an extra event when the Activity is not a valid AS value.', async(): Promise<void> => {
source.addResource = jest.fn(async(): Promise<any> => ({
'http://example.com/path': new RepresentationMetadata(
{ path: 'http://example.com/path' },
{ [SOLID_AS.terms.Activity.value]: 'SomethingRandom' },
),
}));
await store.addResource({ path: 'http://example.org/foo/bar' }, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(0);
expect(deletedCallback).toHaveBeenCalledTimes(0);
});
});

View File

@@ -1,5 +1,6 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Patch } from '../../../../src/http/representation/Patch';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler';
import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher';
import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler';
@@ -20,7 +21,9 @@ describe('A RepresentationPatchHandler', (): void => {
beforeEach(async(): Promise<void> => {
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
setRepresentation: jest.fn().mockResolvedValue([ identifier ]),
setRepresentation: jest.fn().mockResolvedValue({
[identifier.path]: new RepresentationMetadata(identifier),
}),
} as any;
input = { source, identifier, patch };
@@ -33,7 +36,9 @@ describe('A RepresentationPatchHandler', (): void => {
});
it('calls the patcher with the representation from the store.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toEqual([ identifier ]);
await expect(handler.handle(input)).resolves.toEqual({
[identifier.path]: new RepresentationMetadata(identifier),
});
expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation });
@@ -45,7 +50,9 @@ describe('A RepresentationPatchHandler', (): void => {
it('calls the patcher with no representation if there is none.', async(): Promise<void> => {
source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError());
await expect(handler.handle(input)).resolves.toEqual([ identifier ]);
await expect(handler.handle(input)).resolves.toEqual({
[identifier.path]: new RepresentationMetadata(identifier),
});
expect(patcher.handleSafe).toHaveBeenCalledTimes(1);
expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch });