feat: Use an IdentifierMap for ResourceStore responses

This commit is contained in:
Joachim Van Herwegen
2022-07-11 11:08:02 +02:00
parent 145758adbf
commit 9a12152253
9 changed files with 196 additions and 279 deletions

View File

@@ -30,7 +30,8 @@ and all the entries in `config/storage/backend`.
This store emits the events that are necessary to emit notifications when resources change.
There are 4 different events that can be emitted:
- `this.emit('changed', identifier, AS.Create | AS.Update | AS.Delete | undefined)`: is emitted for every resource that was changed/effected by a call to the store.
- `this.emit('changed', identifier, activity)`: is emitted for every resource that was changed/effected by a call to the store.
With activity being undefined or one of the available ActivityStream terms.
- `this.emit(AS.Create, identifier)`: is emitted for every resource that was created by the call to the store.
- `this.emit(AS.Update, identifier)`: is emitted for every resource that was updated by the call to the store.
- `this.emit(AS.Delete, identifier)`: is emitted for every resource that was deleted by the call to the store.

View File

@@ -1,11 +1,12 @@
import { getLoggerFor } from '../../logging/LogUtil';
import type { ResourceStore } from '../../storage/ResourceStore';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { find } from '../../util/IterableUtil';
import { AS, SOLID_AS } from '../../util/Vocabularies';
import { CreatedResponseDescription } from '../output/response/CreatedResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import { createResourceIdentifier } from '../representation/ResourceIdentifier';
import type { OperationHandlerInput } from './OperationHandler';
import { OperationHandler } from './OperationHandler';
@@ -37,10 +38,12 @@ export class PostOperationHandler extends OperationHandler {
this.logger.warn('POST requests require the Content-Type header to be set');
throw new BadRequestHttpError('POST requests require the Content-Type header to be set');
}
const result = await this.store.addResource(operation.target, operation.body, operation.conditions);
const createdIdentifier = Object.entries(result).find(
([ , value ]): boolean => value.get(SOLID_AS.terms.Activity)?.value === AS.Create,
)![0];
return new CreatedResponseDescription(createResourceIdentifier(createdIdentifier));
const changes = await this.store.addResource(operation.target, operation.body, operation.conditions);
const createdIdentifier = find(changes.keys(), (identifier): boolean =>
Boolean(changes.get(identifier)?.has(SOLID_AS.terms.Activity, AS.terms.Create)));
if (!createdIdentifier) {
throw new InternalServerError('Operation was successful but no created identifier was returned.');
}
return new CreatedResponseDescription(createdIdentifier);
}
}

View File

@@ -14,10 +14,3 @@ export interface ResourceIdentifier {
export function isResourceIdentifier(object: any): object is ResourceIdentifier {
return object && (typeof object.path === 'string');
}
/**
* Factory function creating a {@link ResourceIdentifier} for convenience.
*/
export function createResourceIdentifier(resourcePath: string): ResourceIdentifier {
return { path: resourcePath };
}

View File

@@ -19,6 +19,8 @@ import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
import { PreconditionFailedHttpError } from '../util/errors/PreconditionFailedHttpError';
import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy';
import { concat } from '../util/IterableUtil';
import { IdentifierMap } from '../util/map/IdentifierMap';
import {
ensureTrailingSlash,
isContainerIdentifier,
@@ -266,24 +268,24 @@ export class DataAccessorBasedStore implements ResourceStore {
// Solid, §5.4: "When a contained resource is deleted,
// the server MUST also delete the associated auxiliary resources"
// https://solid.github.io/specification/protocol#deleting-resources
const changes: ChangeMap = {};
const changes: ChangeMap = new IdentifierMap();
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
const auxiliaries = this.auxiliaryStrategy.getAuxiliaryIdentifiers(identifier);
for (const deletedId of await this.safelyDeleteAuxiliaryResources(auxiliaries)) {
changes[deletedId.path] = this.createActivityMetadata(deletedId, AS.Delete);
this.addActivityMetadata(changes, deletedId, AS.terms.Delete);
}
}
if (!this.identifierStrategy.isRootContainer(identifier)) {
const container = this.identifierStrategy.getParentContainer(identifier);
changes[container.path] = this.createActivityMetadata(container, AS.Update);
this.addActivityMetadata(changes, container, AS.terms.Update);
// Update modified date of parent
await this.updateContainerModifiedDate(container);
}
await this.accessor.deleteResource(identifier);
changes[identifier.path] = this.createActivityMetadata(identifier, AS.Delete);
this.addActivityMetadata(changes, identifier, AS.terms.Delete);
return changes;
}
@@ -385,18 +387,17 @@ export class DataAccessorBasedStore implements ResourceStore {
// Solid, §5.3: "Servers MUST create intermediate containers and include corresponding containment triples
// in container representations derived from the URI path component of PUT and PATCH requests."
// https://solid.github.io/specification/protocol#writing-resources
let changes: ChangeMap = {};
let changes: ChangeMap = new IdentifierMap();
if (!this.identifierStrategy.isRootContainer(identifier) && !exists) {
const parent = this.identifierStrategy.getParentContainer(identifier);
if (!createContainers) {
changes[parent.path] = this.createActivityMetadata(parent, AS.Update);
} else {
const createdContainers = await this.createRecursiveContainers(parent);
changes = { ...changes, ...createdContainers };
if (Object.keys(createdContainers).length === 0) {
changes[parent.path] = this.createActivityMetadata(parent, AS.Update);
}
if (createContainers) {
changes = await this.createRecursiveContainers(parent);
}
// No changes means the parent container exists and will be updated
if (changes.size === 0) {
this.addActivityMetadata(changes, parent, AS.terms.Update);
}
// Parent container is also modified
@@ -410,7 +411,7 @@ export class DataAccessorBasedStore implements ResourceStore {
this.accessor.writeContainer(identifier, representation.metadata) :
this.accessor.writeDocument(identifier, representation.data, representation.metadata));
changes[identifier.path] = this.createActivityMetadata(identifier, exists ? AS.Update : AS.Create);
this.addActivityMetadata(changes, identifier, exists ? AS.terms.Update : AS.terms.Create);
return changes;
}
@@ -609,7 +610,7 @@ export class DataAccessorBasedStore implements ResourceStore {
// Verify whether the container already exists
try {
const metadata = await this.getNormalizedMetadata(container);
// See #480
// See https://github.com/CommunitySolidServer/CommunitySolidServer/issues/480
// Solid, §3.1: "If two URIs differ only in the trailing slash, and the server has associated a resource with
// one of them, then the other URI MUST NOT correspond to another resource. Instead, the server MAY respond to
// requests for the latter URI with a 301 redirect to the former."
@@ -617,7 +618,7 @@ export class DataAccessorBasedStore implements ResourceStore {
if (!isContainerPath(metadata.identifier.value)) {
throw new ForbiddenHttpError(`Creating container ${container.path} conflicts with an existing resource.`);
}
return {};
return new IdentifierMap();
} catch (error: unknown) {
if (!NotFoundHttpError.isInstance(error)) {
throw error;
@@ -625,15 +626,21 @@ export class DataAccessorBasedStore implements ResourceStore {
}
// Create the container, starting with its parent
const ancestors = this.identifierStrategy.isRootContainer(container) ?
{} :
const ancestors: ChangeMap = this.identifierStrategy.isRootContainer(container) ?
new IdentifierMap() :
await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container));
const changes = await this.writeData(container, new BasicRepresentation([], container), true, false, false);
return { ...changes, ...ancestors };
return new IdentifierMap(concat([ changes, ancestors ]));
}
private createActivityMetadata(id: ResourceIdentifier, activity: string): RepresentationMetadata {
return new RepresentationMetadata(id, { [SOLID_AS.terms.Activity.value]: activity });
/**
* Generates activity metadata for a resource and adds it to the {@link ChangeMap}
* @param map - ChangeMap to update.
* @param id - Identifier of the resource being changed.
* @param activity - Which activity is taking place.
*/
private addActivityMetadata(map: ChangeMap, id: ResourceIdentifier, activity: NamedNode): void {
map.set(id, new RepresentationMetadata(id, { [SOLID_AS.Activity]: activity }));
}
}

View File

@@ -7,6 +7,9 @@ import { AS, SOLID_AS } from '../util/Vocabularies';
import type { Conditions } from './Conditions';
import type { ResourceStore, ChangeMap } from './ResourceStore';
// The ActivityStream terms for which we emit an event
const emittedActivities: Set<string> = new Set([ AS.Create, AS.Delete, AS.Update ]);
/**
* Store that notifies listeners of changes to its source
* by emitting a `changed` event.
@@ -50,11 +53,11 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
}
private emitChanged(changes: ChangeMap): ChangeMap {
for (const [ key, value ] of Object.entries(changes)) {
const activity = value.get(SOLID_AS.terms.Activity)?.value;
this.emit('changed', { path: key }, activity);
if (activity && [ AS.Create, AS.Delete, AS.Update ].includes(activity)) {
this.emit(activity, { path: key });
for (const [ identifier, metadata ] of changes) {
const activity = metadata.get(SOLID_AS.terms.Activity);
this.emit('changed', identifier, activity);
if (activity && emittedActivities.has(activity.value)) {
this.emit(activity.value, identifier);
}
}

View File

@@ -3,15 +3,16 @@ import type { Representation } from '../http/representation/Representation';
import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import type { IdentifierMap } from '../util/map/IdentifierMap';
import type { Conditions } from './Conditions';
import type { ResourceSet } from './ResourceSet';
/**
* An object containing one property for each resource that was created, updated or deleted
* by this operation. Where the key of the property is the path of the resource (string) and the value is an
* instance of RepresentationMetadata containing extra information about the change of the resource.
* An {@link IdentifierMap} containing one entry for each resource that was created, updated or deleted
* by this operation. Where the value is a {@link RepresentationMetadata}
* containing extra information about the change of the resource.
*/
export type ChangeMap = Record<string, RepresentationMetadata>;
export type ChangeMap = IdentifierMap<RepresentationMetadata>;
/**
* A ResourceStore represents a collection of resources.

View File

@@ -4,33 +4,29 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRe
import type { Representation } from '../../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore, ChangeMap } from '../../../../src/storage/ResourceStore';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { IdentifierMap } from '../../../../src/util/map/IdentifierMap';
import { AS, SOLID_AS, SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => {
let operation: Operation;
let body: Representation;
const conditions = new BasicConditions({});
let store: ResourceStore;
let store: jest.Mocked<ResourceStore>;
let handler: PostOperationHandler;
beforeEach(async(): Promise<void> => {
body = new BasicRepresentation('', 'text/turtle');
operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}};
store = {
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;
addResource: jest.fn().mockResolvedValue(new IdentifierMap([
[{ path: 'https://example.com/parent/newPath' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Create }) ],
[{ path: 'https://example.com/parent/' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Update }) ],
])),
} as any;
handler = new PostOperationHandler(store);
});
@@ -54,4 +50,11 @@ describe('A PostOperationHandler', (): void => {
expect(store.addResource).toHaveBeenCalledTimes(1);
expect(store.addResource).toHaveBeenLastCalledWith(operation.target, body, conditions);
});
it('errors if the store returns no created identifier.', async(): Promise<void> => {
store.addResource.mockResolvedValueOnce(new IdentifierMap([
[{ path: 'https://example.com/parent/' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Update }) ],
]));
await expect(handler.handle({ operation })).rejects.toThrow(InternalServerError);
});
});

View File

@@ -266,21 +266,17 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
const result = await store.addResource(resourceID, representation);
expect(Object.keys(result)).toEqual([
root,
expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
]);
expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
const generatedID = Object.keys(result).find((key): boolean => key !== root)!;
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined();
expect(generatedID).toMatch(new RegExp(`^${root}[^/]+$`, 'u'));
expect(generatedID.path).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);
expect(accessor.data[generatedID.path]).toBeDefined();
await expect(arrayifyStream(accessor.data[generatedID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[generatedID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(result.get(generatedID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
});
it('can write containers.', async(): Promise<void> => {
@@ -290,24 +286,21 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom([ '<> a <http://test.com/coolContainer>.' ]);
const result = await store.addResource(resourceID, representation);
expect(Object.keys(result)).toEqual([
root,
expect.stringMatching(new RegExp(`^${root}[^/]+?/$`, 'u')),
]);
expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update);
const generatedID = Object.keys(result).find((key): boolean => key !== root)!;
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
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);
expect(generatedID.path).toMatch(new RegExp(`^${root}[^/]+?/$`, 'u'));
const { data, metadata } = await store.getRepresentation({ path: generatedID });
expect(accessor.data[generatedID.path]).toBeDefined();
expect(accessor.data[generatedID.path].metadata.contentType).toBeUndefined();
expect(result.get(generatedID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
const { data, metadata } = await store.getRepresentation(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 === generatedID &&
expect(quads.some((entry): boolean => entry.subject.value === generatedID.path &&
entry.object.value === 'http://test.com/coolContainer')).toBeTruthy();
});
@@ -316,13 +309,10 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, '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);
const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get({ path: `${root}newName` })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
});
it('errors on a slug ending on / without Link rel:type Container header.', async(): Promise<void> => {
@@ -337,8 +327,7 @@ describe('A DataAccessorBasedStore', (): void => {
.toThrow('Only slugs used to create containers can end with a `/`.');
});
it('creates a URI when the incoming slug does not end with /, ' +
'but has a Link rel:type Container header.', async(): Promise<void> => {
it('adds a / at the end if the request metadata contains rdf:type ldp:Container.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(RDF.terms.type, LDP.terms.Container);
@@ -346,12 +335,9 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom([ `` ]);
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
[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);
expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get({ path: `${root}newContainer/` })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
});
it('generates a new URI if adding the slug would create an existing URI.', async(): Promise<void> => {
@@ -372,13 +358,10 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.add(SOLID_HTTP.terms.slug, '&%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);
const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get({ path: `${root}%26%26` })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
});
it('errors if the slug contains a slash.', async(): Promise<void> => {
@@ -442,11 +425,9 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}> a <coolContainer>.` ]);
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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(1);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
expect(mock).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenLastCalledWith(resourceID);
@@ -475,13 +456,10 @@ describe('A DataAccessorBasedStore', (): void => {
it('can write resources.', async(): Promise<void> => {
const resourceID = { 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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.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());
@@ -495,13 +473,10 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.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());
@@ -509,15 +484,12 @@ describe('A DataAccessorBasedStore', (): void => {
expect(accessor.data[root].metadata.get(GENERATED_PREDICATE)).toBeUndefined();
});
it('can overwrite resources which does not update parent metadata.', async(): Promise<void> => {
it('can overwrite resources that do not update parent metadata.', async(): Promise<void> => {
const resourceID = { 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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.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());
@@ -525,11 +497,9 @@ describe('A DataAccessorBasedStore', (): void => {
// Parent metadata does not get updated if the resource already exists
representation = new BasicRepresentation('updatedText', 'text/plain');
mockDate.mockReturnValue(later);
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);
const result2 = await store.setRepresentation(resourceID, representation);
expect(result2.size).toBe(1);
expect(result2.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.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());
@@ -540,13 +510,10 @@ 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);
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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.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();
@@ -556,13 +523,10 @@ describe('A DataAccessorBasedStore', (): void => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete accessor.data[root];
const resourceID = { 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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
});
@@ -575,13 +539,10 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom(
[ quad(namedNode(`${root}resource/`), namedNode('a'), namedNode('coolContainer')) ],
);
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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
});
@@ -601,17 +562,12 @@ describe('A DataAccessorBasedStore', (): void => {
it('creates recursive containers when needed.', async(): Promise<void> => {
const resourceID = { 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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(4);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get({ path: `${root}a/` })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
expect(result.get({ path: `${root}a/b/` })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
expect(result.get({ path: `${root}a/b/resource` })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.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);
@@ -636,11 +592,9 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.removeAll(RDF.terms.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([]);
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);
const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(1);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Create);
expect(accessor.data[resourceID.path]).toBeTruthy();
expect(Object.keys(accessor.data)).toHaveLength(1);
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
@@ -725,10 +679,10 @@ describe('A DataAccessorBasedStore', (): void => {
it('will delete resources.', async(): Promise<void> => {
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),
});
const result = await store.deleteResource(resourceID);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Delete);
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();
@@ -736,9 +690,9 @@ describe('A DataAccessorBasedStore', (): void => {
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(
{ [root]: expect.any(RepresentationMetadata) },
);
const result = await store.deleteResource({ path: root });
expect(result.size).toBe(1);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Delete);
expect(accessor.data[root]).toBeUndefined();
});
@@ -749,15 +703,10 @@ describe('A DataAccessorBasedStore', (): void => {
accessor.data[resourceID.path] = new BasicRepresentation(representation.data, storageMetadata);
accessor.data[auxResourceID.path] = representation;
auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true);
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);
const result = await store.deleteResource(auxResourceID);
expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(auxResourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Delete);
expect(accessor.data[auxResourceID.path]).toBeUndefined();
});
@@ -767,17 +716,11 @@ describe('A DataAccessorBasedStore', (): void => {
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);
const result = await store.deleteResource(resourceID);
expect(result.size).toBe(3);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Delete);
expect(result.get(auxResourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeUndefined();
});
@@ -796,12 +739,10 @@ describe('A DataAccessorBasedStore', (): void => {
});
const { logger } = store as any;
logger.error = jest.fn();
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),
});
const result = await store.deleteResource(resourceID);
expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Update);
expect(result.get(resourceID)?.get(SOLID_AS.terms.Activity)).toEqual(AS.terms.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeDefined();
expect(logger.error).toHaveBeenCalledTimes(1);

View File

@@ -2,7 +2,8 @@ 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 type { ChangeMap, ResourceStore } from '../../../src/storage/ResourceStore';
import { IdentifierMap } from '../../../src/util/map/IdentifierMap';
import { AS, SOLID_AS } from '../../../src/util/Vocabularies';
describe('A MonitoringStore', (): void => {
@@ -14,55 +15,31 @@ describe('A MonitoringStore', (): 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 },
),
};
const addResourceReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Create }) ],
[{ path: 'http://example.org/foo/bar/' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Update }) ],
]);
const setRepresentationReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Update }) ],
]);
const deleteResourceReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Delete }) ],
[{ path: 'http://example.org/foo/bar/' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Update }) ],
]);
const modifyResourceReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/old' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Delete }) ],
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Create }) ],
[{ path: 'http://example.org/foo/bar/' }, new RepresentationMetadata({ [SOLID_AS.Activity]: AS.terms.Update }) ],
]);
beforeEach(async(): Promise<void> => {
source = {
getRepresentation: jest.fn(async(): Promise<any> => ({ success: true })),
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),
getRepresentation: jest.fn().mockResolvedValue({ success: true }),
addResource: jest.fn().mockResolvedValue(addResourceReturnMock),
setRepresentation: jest.fn().mockResolvedValue(setRepresentationReturnMock),
deleteResource: jest.fn().mockResolvedValue(deleteResourceReturnMock),
modifyResource: jest.fn().mockResolvedValue(modifyResourceReturnMock),
hasResource: jest.fn().mockResolvedValue(true),
};
store = new MonitoringStore(source);
@@ -76,13 +53,6 @@ describe('A MonitoringStore', (): void => {
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> => {
await expect(store.getRepresentation({ path: 'getPath' }, {})).resolves.toEqual({ success: true });
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
@@ -96,10 +66,8 @@ 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.toEqual({
'http://example.org/foo/bar/new': expect.any(RepresentationMetadata),
'http://example.org/foo/bar/': expect.any(RepresentationMetadata),
});
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation))
.resolves.toBe(addResourceReturnMock);
expect(source.addResource).toHaveBeenCalledTimes(1);
expect(source.addResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined);
});
@@ -109,8 +77,8 @@ describe('A MonitoringStore', (): void => {
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.terms.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Create);
expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledTimes(1);
@@ -130,7 +98,7 @@ describe('A MonitoringStore', (): void => {
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(1);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Update);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
@@ -149,8 +117,8 @@ describe('A MonitoringStore', (): void => {
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(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(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.terms.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Delete);
expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
@@ -170,9 +138,9 @@ describe('A MonitoringStore', (): void => {
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
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(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Create);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.terms.Update);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }, AS.terms.Delete);
expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledTimes(1);
@@ -182,18 +150,15 @@ describe('A MonitoringStore', (): void => {
});
it('calls hasResource directly from the source.', async(): Promise<void> => {
await expect(store.hasResource({ path: 'http://example.org/foo/bar' })).resolves.toBeUndefined();
await expect(store.hasResource({ path: 'http://example.org/foo/bar' })).resolves.toBe(true);
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' },
),
}));
source.addResource = jest.fn().mockResolvedValue(new IdentifierMap([
[{ path: 'http://example.org/path' }, new RepresentationMetadata({ [SOLID_AS.Activity]: 'SomethingRandom' }) ],
]));
await store.addResource({ path: 'http://example.org/foo/bar' }, {} as Patch);