feat: Support Add/Remove notifications on containers

This commit is contained in:
Joachim Van Herwegen 2023-02-07 13:06:19 +01:00
parent 9e1e65cdb9
commit 134237a80f
17 changed files with 326 additions and 83 deletions

View File

@ -13,6 +13,12 @@
"@type": "WaterfallHandler", "@type": "WaterfallHandler",
"handlers": [ "handlers": [
{ "@type": "DeleteNotificationGenerator" }, { "@type": "DeleteNotificationGenerator" },
{
"@type": "AddRemoveNotificationGenerator",
"store": {
"@id": "urn:solid-server:default:ResourceStore"
}
},
{ {
"@type": "ActivityNotificationGenerator", "@type": "ActivityNotificationGenerator",
"store": { "store": {

View File

@ -9,7 +9,7 @@ import type { ResourceIdentifier } from './ResourceIdentifier';
import { isResourceIdentifier } from './ResourceIdentifier'; import { isResourceIdentifier } from './ResourceIdentifier';
export type MetadataIdentifier = ResourceIdentifier | NamedNode | BlankNode; export type MetadataIdentifier = ResourceIdentifier | NamedNode | BlankNode;
export type MetadataValue = NamedNode | Literal | string | (NamedNode | Literal | string)[]; export type MetadataValue = NamedNode | BlankNode | Literal | string | (NamedNode | Literal | BlankNode | string)[];
export type MetadataRecord = Record<string, MetadataValue>; export type MetadataRecord = Record<string, MetadataValue>;
export type MetadataGraph = NamedNode | BlankNode | DefaultGraph | string; export type MetadataGraph = NamedNode | BlankNode | DefaultGraph | string;
@ -253,7 +253,7 @@ export class RepresentationMetadata {
* Runs the given function on all predicate/object pairs, but only converts the predicate to a named node once. * Runs the given function on all predicate/object pairs, but only converts the predicate to a named node once.
*/ */
private forQuads(predicate: NamedNode, object: MetadataValue, private forQuads(predicate: NamedNode, object: MetadataValue,
forFn: (pred: NamedNode, obj: NamedNode | Literal) => void): this { forFn: (pred: NamedNode, obj: NamedNode | BlankNode | Literal) => void): this {
const objects = Array.isArray(object) ? object : [ object ]; const objects = Array.isArray(object) ? object : [ object ];
for (const obj of objects) { for (const obj of objects) {
forFn(predicate, toObjectTerm(obj, true)); forFn(predicate, toObjectTerm(obj, true));

View File

@ -311,6 +311,7 @@ export * from './server/middleware/WebSocketAdvertiser';
// Server/Notifications/Generate // Server/Notifications/Generate
export * from './server/notifications/generate/ActivityNotificationGenerator'; export * from './server/notifications/generate/ActivityNotificationGenerator';
export * from './server/notifications/generate/AddRemoveNotificationGenerator';
export * from './server/notifications/generate/DeleteNotificationGenerator'; export * from './server/notifications/generate/DeleteNotificationGenerator';
export * from './server/notifications/generate/NotificationGenerator'; export * from './server/notifications/generate/NotificationGenerator';
export * from './server/notifications/generate/StateNotificationGenerator'; export * from './server/notifications/generate/StateNotificationGenerator';

View File

@ -1,3 +1,4 @@
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { GenericEventEmitter } from '../../util/GenericEventEmitter'; import type { GenericEventEmitter } from '../../util/GenericEventEmitter';
import { createGenericEventEmitterClass } from '../../util/GenericEventEmitter'; import { createGenericEventEmitterClass } from '../../util/GenericEventEmitter';
@ -8,8 +9,11 @@ import type { AS, VocabularyTerm, VocabularyValue } from '../../util/Vocabularie
* Both generic `change` events and ActivityStream-specific events are emitted. * Both generic `change` events and ActivityStream-specific events are emitted.
*/ */
export type ActivityEmitter = export type ActivityEmitter =
GenericEventEmitter<'changed', (target: ResourceIdentifier, activity: VocabularyTerm<typeof AS>) => void> & GenericEventEmitter<'changed',
GenericEventEmitter<VocabularyValue<typeof AS>, (target: ResourceIdentifier) => void>; (target: ResourceIdentifier, activity: VocabularyTerm<typeof AS>, metadata: RepresentationMetadata) => void>
&
GenericEventEmitter<VocabularyValue<typeof AS>,
(target: ResourceIdentifier, metadata: RepresentationMetadata) => void>;
/** /**
* A class implementation of {@link ActivityEmitter}. * A class implementation of {@link ActivityEmitter}.

View File

@ -1,3 +1,4 @@
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil'; import { getLoggerFor } from '../../logging/LogUtil';
import { createErrorMessage } from '../../util/errors/ErrorUtil'; import { createErrorMessage } from '../../util/errors/ErrorUtil';
@ -28,14 +29,15 @@ export class ListeningActivityHandler extends StaticHandler {
this.storage = storage; this.storage = storage;
this.handler = handler; this.handler = handler;
emitter.on('changed', (topic, activity): void => { emitter.on('changed', (topic, activity, metadata): void => {
this.emit(topic, activity).catch((error): void => { this.emit(topic, activity, metadata).catch((error): void => {
this.logger.error(`Something went wrong emitting notifications: ${createErrorMessage(error)}`); this.logger.error(`Something went wrong emitting notifications: ${createErrorMessage(error)}`);
}); });
}); });
} }
private async emit(topic: ResourceIdentifier, activity: VocabularyTerm<typeof AS>): Promise<void> { private async emit(topic: ResourceIdentifier, activity: VocabularyTerm<typeof AS>,
metadata: RepresentationMetadata): Promise<void> {
const channelIds = await this.storage.getAll(topic); const channelIds = await this.storage.getAll(topic);
for (const id of channelIds) { for (const id of channelIds) {
@ -57,7 +59,7 @@ export class ListeningActivityHandler extends StaticHandler {
// No need to wait on this to resolve before going to the next channel. // No need to wait on this to resolve before going to the next channel.
// Prevent failed notification from blocking other notifications. // Prevent failed notification from blocking other notifications.
this.handler.handleSafe({ channel, activity, topic }) this.handler.handleSafe({ channel, activity, topic, metadata })
.then((): Promise<void> => { .then((): Promise<void> => {
// Update the `lastEmit` value if the channel has a rate limit // Update the `lastEmit` value if the channel has a rate limit
if (channel.rate) { if (channel.rate) {

View File

@ -1,3 +1,4 @@
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { AS, VocabularyTerm } from '../../util/Vocabularies'; import type { AS, VocabularyTerm } from '../../util/Vocabularies';
@ -7,6 +8,7 @@ export interface NotificationHandlerInput {
topic: ResourceIdentifier; topic: ResourceIdentifier;
channel: NotificationChannel; channel: NotificationChannel;
activity?: VocabularyTerm<typeof AS>; activity?: VocabularyTerm<typeof AS>;
metadata?: RepresentationMetadata;
} }
/** /**

View File

@ -0,0 +1,56 @@
import { getETag } from '../../../storage/Conditions';
import type { ResourceStore } from '../../../storage/ResourceStore';
import { InternalServerError } from '../../../util/errors/InternalServerError';
import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError';
import { AS } from '../../../util/Vocabularies';
import type { Notification } from '../Notification';
import { CONTEXT_ACTIVITYSTREAMS, CONTEXT_NOTIFICATION } from '../Notification';
import type { NotificationHandlerInput } from '../NotificationHandler';
import { NotificationGenerator } from './NotificationGenerator';
/**
* A {@link NotificationGenerator} specifically for Add/Remove notifications.
* Creates the notification so the `target` is set to input topic,
* and the `object` value is extracted from the provided metadata.
*/
export class AddRemoveNotificationGenerator extends NotificationGenerator {
private readonly store: ResourceStore;
public constructor(store: ResourceStore) {
super();
this.store = store;
}
public async canHandle({ activity }: NotificationHandlerInput): Promise<void> {
if (!activity || (!activity.equals(AS.terms.Add) && !activity.equals(AS.terms.Remove))) {
throw new NotImplementedHttpError(`Only Add/Remove activity updates are supported.`);
}
}
public async handle({ activity, topic, metadata }: NotificationHandlerInput): Promise<Notification> {
const representation = await this.store.getRepresentation(topic, {});
representation.data.destroy();
const state = getETag(representation.metadata);
const objects = metadata?.getAll(AS.terms.object);
if (!objects || objects.length === 0) {
throw new InternalServerError(`Missing as:object metadata for ${activity?.value} activity on ${topic.path}`);
}
if (objects.length > 1) {
throw new InternalServerError(`Found more than one as:object for ${activity?.value} activity on ${topic.path}`);
}
return {
'@context': [
CONTEXT_ACTIVITYSTREAMS,
CONTEXT_NOTIFICATION,
],
id: `urn:${Date.now()}:${topic.path}`,
type: activity!.value.slice(AS.namespace.length),
object: objects[0].value,
target: topic.path,
state,
published: new Date().toISOString(),
};
}
}

View File

@ -324,7 +324,8 @@ export class DataAccessorBasedStore implements ResourceStore {
if (!this.identifierStrategy.isRootContainer(identifier)) { if (!this.identifierStrategy.isRootContainer(identifier)) {
const container = this.identifierStrategy.getParentContainer(identifier); const container = this.identifierStrategy.getParentContainer(identifier);
this.addActivityMetadata(changes, container, AS.terms.Update);
this.addContainerActivity(changes, container, false, identifier);
// Update modified date of parent // Update modified date of parent
await this.updateContainerModifiedDate(container); await this.updateContainerModifiedDate(container);
@ -424,7 +425,7 @@ export class DataAccessorBasedStore implements ResourceStore {
const changes: ChangeMap = new IdentifierMap(); const changes: ChangeMap = new IdentifierMap();
// Tranform representation data to quads and add them to the metadata object // Transform representation data to quads and add them to the metadata object
const metadata = new RepresentationMetadata(subjectIdentifier); const metadata = new RepresentationMetadata(subjectIdentifier);
const quads = await arrayifyStream(representation.data); const quads = await arrayifyStream(representation.data);
metadata.addQuads(quads); metadata.addQuads(quads);
@ -482,7 +483,7 @@ export class DataAccessorBasedStore implements ResourceStore {
// No changes means the parent container exists and will be updated // No changes means the parent container exists and will be updated
if (changes.size === 0) { if (changes.size === 0) {
this.addActivityMetadata(changes, parent, AS.terms.Update); this.addContainerActivity(changes, parent, true, identifier);
} }
// Parent container is also modified // Parent container is also modified
@ -710,4 +711,19 @@ export class DataAccessorBasedStore implements ResourceStore {
private addActivityMetadata(map: ChangeMap, id: ResourceIdentifier, activity: NamedNode): void { private addActivityMetadata(map: ChangeMap, id: ResourceIdentifier, activity: NamedNode): void {
map.set(id, new RepresentationMetadata(id, { [SOLID_AS.activity]: activity })); map.set(id, new RepresentationMetadata(id, { [SOLID_AS.activity]: activity }));
} }
/**
* Generates activity metadata specifically for Add/Remove events on a container.
* @param map - ChangeMap to update.
* @param id - Identifier of the container.
* @param add - If there is a resource being added (`true`) or removed (`false`).
* @param object - The object that is being added/removed.
*/
private addContainerActivity(map: ChangeMap, id: ResourceIdentifier, add: boolean, object: ResourceIdentifier): void {
const metadata = new RepresentationMetadata({
[SOLID_AS.activity]: add ? AS.terms.Add : AS.terms.Remove,
[AS.object]: namedNode(object.path),
});
map.set(id, metadata);
}
} }

View File

@ -9,7 +9,7 @@ import type { Conditions } from './Conditions';
import type { ResourceStore, ChangeMap } from './ResourceStore'; import type { ResourceStore, ChangeMap } from './ResourceStore';
// The ActivityStream terms for which we emit an event // The ActivityStream terms for which we emit an event
const knownActivities = [ AS.terms.Create, AS.terms.Delete, AS.terms.Update ]; const knownActivities = [ AS.terms.Add, AS.terms.Create, AS.terms.Delete, AS.terms.Remove, AS.terms.Update ];
/** /**
* Store that notifies listeners of changes to its source * Store that notifies listeners of changes to its source
@ -57,8 +57,8 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
for (const [ identifier, metadata ] of changes) { for (const [ identifier, metadata ] of changes) {
const activity = metadata.get(SOLID_AS.terms.activity); const activity = metadata.get(SOLID_AS.terms.activity);
if (this.isKnownActivity(activity)) { if (this.isKnownActivity(activity)) {
this.emit('changed', identifier, activity); this.emit('changed', identifier, activity, metadata);
this.emit(activity.value, identifier); this.emit(activity.value, identifier, metadata);
} }
} }

View File

@ -145,8 +145,12 @@ export const ACP = createVocabulary('http://www.w3.org/ns/solid/acp#',
); );
export const AS = createVocabulary('https://www.w3.org/ns/activitystreams#', export const AS = createVocabulary('https://www.w3.org/ns/activitystreams#',
'object',
'Add',
'Create', 'Create',
'Delete', 'Delete',
'Remove',
'Update', 'Update',
); );

View File

@ -223,6 +223,66 @@ describe.each(stores)('A server supporting WebSocketChannel2023 using %s', (name
expect(message).toBe('Notification channel has expired'); expect(message).toBe('Notification channel has expired');
}); });
it('emits container notifications if contents get added or removed.', async(): Promise<void> => {
const resource = joinUrl(baseUrl, '/resource');
// Subscribing to the base URL, which is the parent container
const { receiveFrom } = await subscribe(notificationType, webId, subscriptionUrl, baseUrl) as any;
const socket = new WebSocket(receiveFrom);
let notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('open', resolve));
let response = await fetch(resource, {
method: 'PUT',
headers: { 'content-type': 'text/plain' },
body: 'abc',
});
expect(response.status).toBe(201);
// Will receive the Add notification
let notification = JSON.parse((await notificationPromise).toString());
// Slightly differs from the other notifications due to the combination of object and target
expect(notification).toEqual({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: expect.stringContaining(baseUrl),
type: 'Add',
object: resource,
target: baseUrl,
published: expect.anything(),
state: expect.anything(),
});
// Reset the notifications promise
notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
response = await fetch(resource, {
method: 'DELETE',
});
expect(response.status).toBe(205);
// Will receive the Remove notification
notification = JSON.parse((await notificationPromise).toString());
expect(notification).toEqual({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: expect.stringContaining(baseUrl),
type: 'Remove',
object: resource,
target: baseUrl,
published: expect.anything(),
state: expect.anything(),
});
socket.close();
});
it('can use other RDF formats and content negotiation when creating a channel.', async(): Promise<void> => { it('can use other RDF formats and content negotiation when creating a channel.', async(): Promise<void> => {
const turtleChannel = ` const turtleChannel = `
_:id <${RDF.type}> <${notificationType}> ; _:id <${RDF.type}> <${notificationType}> ;

View File

@ -1,5 +1,6 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import type { Server } from 'http'; import type { Server } from 'http';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import { UnsecureWebSocketsProtocol } from '../../../src/http/UnsecureWebSocketsProtocol'; import { UnsecureWebSocketsProtocol } from '../../../src/http/UnsecureWebSocketsProtocol';
import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../src/server/HttpRequest';
import { BaseActivityEmitter } from '../../../src/server/notifications/ActivityEmitter'; import { BaseActivityEmitter } from '../../../src/server/notifications/ActivityEmitter';
@ -26,6 +27,7 @@ class DummySocket extends EventEmitter {
describe('An UnsecureWebSocketsProtocol', (): void => { describe('An UnsecureWebSocketsProtocol', (): void => {
let server: Server; let server: Server;
let webSocket: DummySocket; let webSocket: DummySocket;
const metadata = new RepresentationMetadata();
const source = new BaseActivityEmitter(); const source = new BaseActivityEmitter();
let protocol: UnsecureWebSocketsProtocol; let protocol: UnsecureWebSocketsProtocol;
@ -67,7 +69,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
describe('before subscribing to resources', (): void => { describe('before subscribing to resources', (): void => {
it('does not emit pub messages.', (): void => { it('does not emit pub messages.', (): void => {
source.emit('changed', { path: 'https://mypod.example/foo/bar' }, AS.terms.Update); source.emit('changed', { path: 'https://mypod.example/foo/bar' }, AS.terms.Update, metadata);
expect(webSocket.messages).toHaveLength(0); expect(webSocket.messages).toHaveLength(0);
}); });
}); });
@ -83,7 +85,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
}); });
it('emits pub messages for that resource.', (): void => { it('emits pub messages for that resource.', (): void => {
source.emit('changed', { path: 'https://mypod.example/foo/bar' }, AS.terms.Update); source.emit('changed', { path: 'https://mypod.example/foo/bar' }, AS.terms.Update, metadata);
expect(webSocket.messages).toHaveLength(1); expect(webSocket.messages).toHaveLength(1);
expect(webSocket.messages.shift()).toBe('pub https://mypod.example/foo/bar'); expect(webSocket.messages.shift()).toBe('pub https://mypod.example/foo/bar');
}); });
@ -100,7 +102,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
}); });
it('emits pub messages for that resource.', (): void => { it('emits pub messages for that resource.', (): void => {
source.emit('changed', { path: 'https://mypod.example/relative/foo' }, AS.terms.Update); source.emit('changed', { path: 'https://mypod.example/relative/foo' }, AS.terms.Update, metadata);
expect(webSocket.messages).toHaveLength(1); expect(webSocket.messages).toHaveLength(1);
expect(webSocket.messages.shift()).toBe('pub https://mypod.example/relative/foo'); expect(webSocket.messages.shift()).toBe('pub https://mypod.example/relative/foo');
}); });

View File

@ -1,4 +1,5 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Logger } from '../../../../src/logging/Logger'; import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil'; import { getLoggerFor } from '../../../../src/logging/LogUtil';
@ -21,6 +22,7 @@ describe('A ListeningActivityHandler', (): void => {
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any; const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' }; const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const activity = AS.terms.Update; const activity = AS.terms.Update;
const metadata = new RepresentationMetadata();
let channel: NotificationChannel; let channel: NotificationChannel;
let storage: jest.Mocked<NotificationChannelStorage>; let storage: jest.Mocked<NotificationChannelStorage>;
let emitter: ActivityEmitter; let emitter: ActivityEmitter;
@ -52,12 +54,12 @@ describe('A ListeningActivityHandler', (): void => {
}); });
it('calls the NotificationHandler if there is an event.', async(): Promise<void> => { it('calls the NotificationHandler if there is an event.', async(): Promise<void> => {
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1); expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ channel, activity, topic }); expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ channel, activity, topic, metadata });
expect(logger.error).toHaveBeenCalledTimes(0); expect(logger.error).toHaveBeenCalledTimes(0);
expect(storage.update).toHaveBeenCalledTimes(0); expect(storage.update).toHaveBeenCalledTimes(0);
}); });
@ -65,12 +67,12 @@ describe('A ListeningActivityHandler', (): void => {
it('updates the lastEmit value of the channel if it has a rate limit.', async(): Promise<void> => { it('updates the lastEmit value of the channel if it has a rate limit.', async(): Promise<void> => {
jest.useFakeTimers(); jest.useFakeTimers();
channel.rate = 10 * 1000; channel.rate = 10 * 1000;
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1); expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ channel, activity, topic }); expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ channel, activity, topic, metadata });
expect(logger.error).toHaveBeenCalledTimes(0); expect(logger.error).toHaveBeenCalledTimes(0);
expect(storage.update).toHaveBeenCalledTimes(1); expect(storage.update).toHaveBeenCalledTimes(1);
expect(storage.update).toHaveBeenLastCalledWith({ expect(storage.update).toHaveBeenLastCalledWith({
@ -84,7 +86,7 @@ describe('A ListeningActivityHandler', (): void => {
channel.rate = 100000; channel.rate = 100000;
channel.lastEmit = Date.now(); channel.lastEmit = Date.now();
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();
@ -95,7 +97,7 @@ describe('A ListeningActivityHandler', (): void => {
it('does not emit an event on channels if their start time has not been reached.', async(): Promise<void> => { it('does not emit an event on channels if their start time has not been reached.', async(): Promise<void> => {
channel.startAt = Date.now() + 100000; channel.startAt = Date.now() + 100000;
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();
@ -107,7 +109,7 @@ describe('A ListeningActivityHandler', (): void => {
storage.getAll.mockResolvedValue([ channel.id, channel.id ]); storage.getAll.mockResolvedValue([ channel.id, channel.id ]);
notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input')); notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input'));
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();
@ -119,7 +121,7 @@ describe('A ListeningActivityHandler', (): void => {
it('logs an error if something goes wrong handling the event.', async(): Promise<void> => { it('logs an error if something goes wrong handling the event.', async(): Promise<void> => {
storage.getAll.mockRejectedValue(new Error('bad event')); storage.getAll.mockRejectedValue(new Error('bad event'));
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0); expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
@ -130,7 +132,7 @@ describe('A ListeningActivityHandler', (): void => {
it('ignores undefined channels.', async(): Promise<void> => { it('ignores undefined channels.', async(): Promise<void> => {
storage.get.mockResolvedValue(undefined); storage.get.mockResolvedValue(undefined);
emitter.emit('changed', topic, activity); emitter.emit('changed', topic, activity, metadata);
await flushPromises(); await flushPromises();

View File

@ -0,0 +1,81 @@
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import {
AddRemoveNotificationGenerator,
} from '../../../../../src/server/notifications/generate/AddRemoveNotificationGenerator';
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
describe('An AddRemoveNotificationGenerator', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/' };
const object: ResourceIdentifier = { path: 'http://example.com/foo' };
const channel: NotificationChannel = {
id: 'id',
topic: topic.path,
type: 'type',
};
let metadata: RepresentationMetadata;
let store: jest.Mocked<ResourceStore>;
let generator: AddRemoveNotificationGenerator;
beforeEach(async(): Promise<void> => {
metadata = new RepresentationMetadata(topic, { [AS.object]: object.path });
const responseMetadata = new RepresentationMetadata({
[RDF.type]: LDP.terms.Resource,
// Needed for ETag
[DC.modified]: new Date().toISOString(),
});
store = {
getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', responseMetadata)),
} as any;
generator = new AddRemoveNotificationGenerator(store);
});
it('only handles Add/Remove activities.', async(): Promise<void> => {
await expect(generator.canHandle({ topic, channel, metadata }))
.rejects.toThrow('Only Add/Remove activity updates are supported.');
await expect(generator.canHandle({ topic, channel, metadata, activity: AS.terms.Add })).resolves.toBeUndefined();
await expect(generator.canHandle({ topic, channel, metadata, activity: AS.terms.Remove })).resolves.toBeUndefined();
});
it('requires one object metadata to be present.', async(): Promise<void> => {
metadata = new RepresentationMetadata();
await expect(generator.handle({ topic, channel, activity: AS.terms.Add })).rejects.toThrow(
'Missing as:object metadata for https://www.w3.org/ns/activitystreams#Add activity on http://example.com/',
);
await expect(generator.handle({ topic, channel, metadata, activity: AS.terms.Add })).rejects.toThrow(
'Missing as:object metadata for https://www.w3.org/ns/activitystreams#Add activity on http://example.com/',
);
metadata = new RepresentationMetadata(topic, { [AS.object]: [ object.path, 'http://example.com/otherObject' ]});
await expect(generator.handle({ topic, channel, metadata, activity: AS.terms.Add })).rejects.toThrow(
'Found more than one as:object for https://www.w3.org/ns/activitystreams#Add activity on http://example.com/',
);
});
it('generates a notification.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
jest.useFakeTimers();
jest.setSystemTime(ms);
await expect(generator.handle({ topic, channel, metadata, activity: AS.terms.Add })).resolves.toEqual({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: `urn:${ms}:http://example.com/`,
type: 'Add',
object: 'http://example.com/foo',
target: 'http://example.com/',
state: expect.stringMatching(/"\d+"/u),
published: date,
});
jest.useRealTimers();
});
});

View File

@ -10,7 +10,6 @@ import { RepresentationMetadata } from '../../../src/http/representation/Represe
import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
import type { DataAccessor } from '../../../src/storage/accessors/DataAccessor'; import type { DataAccessor } from '../../../src/storage/accessors/DataAccessor';
import { BasicConditions } from '../../../src/storage/BasicConditions'; import { BasicConditions } from '../../../src/storage/BasicConditions';
import { DataAccessorBasedStore } from '../../../src/storage/DataAccessorBasedStore'; import { DataAccessorBasedStore } from '../../../src/storage/DataAccessorBasedStore';
import { INTERNAL_QUADS } from '../../../src/util/ContentTypes'; import { INTERNAL_QUADS } from '../../../src/util/ContentTypes';
import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
@ -265,7 +264,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(RDF.terms.type, LDP.terms.Container); representation.metadata.add(RDF.terms.type, LDP.terms.Container);
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!; const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined(); expect(generatedID).toBeDefined();
@ -278,7 +277,7 @@ describe('A DataAccessorBasedStore', (): void => {
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!; const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined(); expect(generatedID).toBeDefined();
@ -288,6 +287,8 @@ describe('A DataAccessorBasedStore', (): void => {
await expect(arrayifyStream(accessor.data[generatedID.path].data)).resolves.toEqual([ resourceData ]); 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(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); expect(result.get(generatedID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create);
expect(result.get(resourceID)?.get(AS.terms.object)?.value).toEqual(generatedID.path);
}); });
it('can write containers.', async(): Promise<void> => { it('can write containers.', async(): Promise<void> => {
@ -296,7 +297,7 @@ describe('A DataAccessorBasedStore', (): void => {
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!; const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined(); expect(generatedID).toBeDefined();
@ -317,7 +318,7 @@ describe('A DataAccessorBasedStore', (): void => {
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get({ path: `${root}newName` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create); expect(result.get({ path: `${root}newName` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create);
}); });
@ -342,7 +343,7 @@ describe('A DataAccessorBasedStore', (): void => {
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get({ path: `${root}newContainer/` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create); expect(result.get({ path: `${root}newContainer/` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create);
}); });
@ -366,7 +367,7 @@ describe('A DataAccessorBasedStore', (): void => {
const result = await store.addResource(resourceID, representation); const result = await store.addResource(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get({ path: `${root}%26%26` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create); expect(result.get({ path: `${root}%26%26` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create);
}); });
@ -459,7 +460,8 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` }; const resourceID = { path: `${root}resource` };
const result = await store.setRepresentation(resourceID, representation); const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get({ path: root })?.get(AS.terms.object)?.value).toEqual(resourceID.path);
expect(result.get(resourceID)?.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 ]); 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[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@ -476,7 +478,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]); representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
const result = await store.setRepresentation(resourceID, representation); const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create);
expect(accessor.data[resourceID.path]).toBeTruthy(); expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
@ -489,7 +491,7 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` }; const resourceID = { path: `${root}resource` };
const result = await store.setRepresentation(resourceID, representation); const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get(resourceID)?.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 ]); 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[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
@ -513,7 +515,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata); representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata);
const result = await store.setRepresentation(resourceID, representation); const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get(resourceID)?.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 ]); 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('notGen'))?.value).toBe('value');
@ -535,7 +537,7 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}a/b/resource` }; const resourceID = { path: `${root}a/b/resource` };
const result = await store.setRepresentation(resourceID, representation); const result = await store.setRepresentation(resourceID, representation);
expect(result.size).toBe(4); expect(result.size).toBe(4);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
expect(result.get({ path: `${root}a/` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create); 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/` })?.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); expect(result.get({ path: `${root}a/b/resource` })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Create);
@ -770,7 +772,8 @@ describe('A DataAccessorBasedStore', (): void => {
accessor.data[resourceID.path] = representation; accessor.data[resourceID.path] = representation;
const result = await store.deleteResource(resourceID); const result = await store.deleteResource(resourceID);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Remove);
expect(result.get({ path: root })?.get(AS.terms.object)?.value).toEqual(resourceID.path);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined(); 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(DC.terms.modified)?.value).toBe(now.toISOString());
@ -794,7 +797,7 @@ describe('A DataAccessorBasedStore', (): void => {
auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true); auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true);
const result = await store.deleteResource(auxResourceID); const result = await store.deleteResource(auxResourceID);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Remove);
expect(result.get(auxResourceID)?.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[auxResourceID.path]).toBeUndefined(); expect(accessor.data[auxResourceID.path]).toBeUndefined();
}); });
@ -807,7 +810,7 @@ describe('A DataAccessorBasedStore', (): void => {
const result = await store.deleteResource(resourceID); const result = await store.deleteResource(resourceID);
expect(result.size).toBe(3); expect(result.size).toBe(3);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Remove);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete); 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(result.get(auxResourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined(); expect(accessor.data[resourceID.path]).toBeUndefined();
@ -830,7 +833,7 @@ describe('A DataAccessorBasedStore', (): void => {
logger.error = jest.fn(); logger.error = jest.fn();
const result = await store.deleteResource(resourceID); const result = await store.deleteResource(resourceID);
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Update); expect(result.get({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Remove);
expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete); expect(result.get(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined(); expect(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeDefined(); expect(accessor.data[auxResourceID.path]).toBeDefined();

View File

@ -10,26 +10,30 @@ describe('A MonitoringStore', (): void => {
let store: MonitoringStore; let store: MonitoringStore;
let source: ResourceStore; let source: ResourceStore;
const id = { path: 'http://example.org/foo/bar/' };
const idNew = { path: 'http://example.org/foo/bar/new' };
const idOld = { path: 'http://example.org/foo/bar/old' };
let changedCallback: () => void; let changedCallback: () => void;
let createdCallback: () => void; let createdCallback: () => void;
let updatedCallback: () => void; let updatedCallback: () => void;
let deletedCallback: () => void; let deletedCallback: () => void;
const addResourceReturnMock: ChangeMap = new IdentifierMap([ const addResourceReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Create }) ], [ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Create }) ],
[{ path: 'http://example.org/foo/bar/' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ], [ id, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ],
]); ]);
const setRepresentationReturnMock: ChangeMap = new IdentifierMap([ const setRepresentationReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ], [ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ],
]); ]);
const deleteResourceReturnMock: ChangeMap = new IdentifierMap([ const deleteResourceReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Delete }) ], [ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Delete }) ],
[{ path: 'http://example.org/foo/bar/' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ], [ id, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ],
]); ]);
const modifyResourceReturnMock: ChangeMap = new IdentifierMap([ const modifyResourceReturnMock: ChangeMap = new IdentifierMap([
[{ path: 'http://example.org/foo/bar/old' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Delete }) ], [ idOld, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Delete }) ],
[{ path: 'http://example.org/foo/bar/new' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Create }) ], [ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Create }) ],
[{ path: 'http://example.org/foo/bar/' }, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ], [ id, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ],
]); ]);
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -61,98 +65,98 @@ describe('A MonitoringStore', (): void => {
it('does not fire a change event after getRepresentation.', async(): Promise<void> => { it('does not fire a change event after getRepresentation.', async(): Promise<void> => {
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await store.getRepresentation({ path: 'http://example.org/foo/bar' }, {}); await store.getRepresentation(id, {});
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
}); });
it('calls addResource directly from the source.', async(): Promise<void> => { it('calls addResource directly from the source.', async(): Promise<void> => {
await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)) await expect(store.addResource(id, {} as Representation))
.resolves.toBe(addResourceReturnMock); .resolves.toBe(addResourceReturnMock);
expect(source.addResource).toHaveBeenCalledTimes(1); expect(source.addResource).toHaveBeenCalledTimes(1);
expect(source.addResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); expect(source.addResource).toHaveBeenLastCalledWith(id, {}, undefined);
}); });
it('fires appropriate events according to the return value of source.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); const result = store.addResource(id, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(2); expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.terms.Update); expect(changedCallback).toHaveBeenCalledWith(id, AS.terms.Update, addResourceReturnMock.get(id));
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Create); expect(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Create, addResourceReturnMock.get(idNew));
expect(createdCallback).toHaveBeenCalledTimes(1); expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); expect(createdCallback).toHaveBeenCalledWith(idNew, addResourceReturnMock.get(idNew));
expect(updatedCallback).toHaveBeenCalledTimes(1); expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); expect(updatedCallback).toHaveBeenCalledWith(id, addResourceReturnMock.get(id));
expect(deletedCallback).toHaveBeenCalledTimes(0); expect(deletedCallback).toHaveBeenCalledTimes(0);
}); });
it('calls setRepresentation directly from the source.', async(): Promise<void> => { it('calls setRepresentation directly from the source.', async(): Promise<void> => {
await expect(store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation)) await expect(store.setRepresentation(id, {} as Representation))
.resolves.toEqual(setRepresentationReturnMock); .resolves.toEqual(setRepresentationReturnMock);
expect(source.setRepresentation).toHaveBeenCalledTimes(1); expect(source.setRepresentation).toHaveBeenCalledTimes(1);
expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); expect(source.setRepresentation).toHaveBeenLastCalledWith(id, {}, undefined);
}); });
it('fires appropriate events according to the return value of source.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); const result = store.setRepresentation(id, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(1); expect(changedCallback).toHaveBeenCalledTimes(1);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Update); expect(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Update, setRepresentationReturnMock.get(idNew));
expect(createdCallback).toHaveBeenCalledTimes(0); expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1); expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); expect(updatedCallback).toHaveBeenCalledWith(idNew, setRepresentationReturnMock.get(idNew));
expect(deletedCallback).toHaveBeenCalledTimes(0); expect(deletedCallback).toHaveBeenCalledTimes(0);
}); });
it('calls deleteResource directly from the source.', async(): Promise<void> => { it('calls deleteResource directly from the source.', async(): Promise<void> => {
await expect(store.deleteResource({ path: 'http://example.org/foo/bar' })) await expect(store.deleteResource(id))
.resolves.toEqual(deleteResourceReturnMock); .resolves.toEqual(deleteResourceReturnMock);
expect(source.deleteResource).toHaveBeenCalledTimes(1); expect(source.deleteResource).toHaveBeenCalledTimes(1);
expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, undefined); expect(source.deleteResource).toHaveBeenLastCalledWith(id, undefined);
}); });
it('fires appropriate events according to the return value of source.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' }); const result = store.deleteResource(id);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(2); expect(changedCallback).toHaveBeenCalledTimes(2);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.terms.Update); expect(changedCallback).toHaveBeenCalledWith(id, AS.terms.Update, deleteResourceReturnMock.get(id));
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Delete); expect(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Delete, deleteResourceReturnMock.get(idNew));
expect(createdCallback).toHaveBeenCalledTimes(0); expect(createdCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1); expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); expect(updatedCallback).toHaveBeenCalledWith(id, deleteResourceReturnMock.get(id));
expect(deletedCallback).toHaveBeenCalledTimes(1); expect(deletedCallback).toHaveBeenCalledTimes(1);
expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); expect(deletedCallback).toHaveBeenCalledWith(idNew, deleteResourceReturnMock.get(idNew));
}); });
it('calls modifyResource directly from the source.', async(): Promise<void> => { it('calls modifyResource directly from the source.', async(): Promise<void> => {
await expect(store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch)) await expect(store.modifyResource(id, {} as Patch))
.resolves.toEqual(modifyResourceReturnMock); .resolves.toEqual(modifyResourceReturnMock);
expect(source.modifyResource).toHaveBeenCalledTimes(1); expect(source.modifyResource).toHaveBeenCalledTimes(1);
expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); expect(source.modifyResource).toHaveBeenLastCalledWith(id, {}, undefined);
}); });
it('fires appropriate events according to the return value of source.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); const result = store.modifyResource(id, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
await result; await result;
expect(changedCallback).toHaveBeenCalledTimes(3); expect(changedCallback).toHaveBeenCalledTimes(3);
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.terms.Create); expect(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Create, modifyResourceReturnMock.get(idNew));
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.terms.Update); expect(changedCallback).toHaveBeenCalledWith(id, AS.terms.Update, modifyResourceReturnMock.get(id));
expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }, AS.terms.Delete); expect(changedCallback).toHaveBeenCalledWith(idOld, AS.terms.Delete, modifyResourceReturnMock.get(idOld));
expect(createdCallback).toHaveBeenCalledTimes(1); expect(createdCallback).toHaveBeenCalledTimes(1);
expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); expect(createdCallback).toHaveBeenCalledWith(idNew, modifyResourceReturnMock.get(idNew));
expect(updatedCallback).toHaveBeenCalledTimes(1); expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); expect(updatedCallback).toHaveBeenCalledWith(id, modifyResourceReturnMock.get(id));
expect(deletedCallback).toHaveBeenCalledTimes(1); expect(deletedCallback).toHaveBeenCalledTimes(1);
expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }); expect(deletedCallback).toHaveBeenCalledWith(idOld, modifyResourceReturnMock.get(idOld));
}); });
it('calls hasResource directly from the source.', async(): Promise<void> => { it('calls hasResource directly from the source.', async(): Promise<void> => {
await expect(store.hasResource({ path: 'http://example.org/foo/bar' })).resolves.toBe(true); await expect(store.hasResource(id)).resolves.toBe(true);
expect(source.hasResource).toHaveBeenCalledTimes(1); expect(source.hasResource).toHaveBeenCalledTimes(1);
expect(source.hasResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }); expect(source.hasResource).toHaveBeenLastCalledWith(id);
}); });
it('should not emit an event when the Activity is not a valid AS value.', async(): Promise<void> => { it('should not emit an event when the Activity is not a valid AS value.', async(): Promise<void> => {
@ -160,7 +164,7 @@ describe('A MonitoringStore', (): void => {
[{ path: 'http://example.org/path' }, new RepresentationMetadata({ [SOLID_AS.activity]: 'SomethingRandom' }) ], [{ path: 'http://example.org/path' }, new RepresentationMetadata({ [SOLID_AS.activity]: 'SomethingRandom' }) ],
])); ]));
await store.addResource({ path: 'http://example.org/foo/bar' }, {} as Patch); await store.addResource(id, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(0); expect(changedCallback).toHaveBeenCalledTimes(0);
expect(createdCallback).toHaveBeenCalledTimes(0); expect(createdCallback).toHaveBeenCalledTimes(0);

View File

@ -35,7 +35,7 @@ export async function subscribe(type: string, webId: string, subscriptionUrl: st
* @param topic - The topic of the notification. * @param topic - The topic of the notification.
* @param type - What type of notification is expected. * @param type - What type of notification is expected.
*/ */
export function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void { export function expectNotification(notification: unknown, topic: string, type: string): void {
const expected: any = { const expected: any = {
'@context': [ '@context': [
'https://www.w3.org/ns/activitystreams', 'https://www.w3.org/ns/activitystreams',