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

@@ -223,6 +223,66 @@ describe.each(stores)('A server supporting WebSocketChannel2023 using %s', (name
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> => {
const turtleChannel = `
_:id <${RDF.type}> <${notificationType}> ;

View File

@@ -1,5 +1,6 @@
import { EventEmitter } from 'events';
import type { Server } from 'http';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import { UnsecureWebSocketsProtocol } from '../../../src/http/UnsecureWebSocketsProtocol';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import { BaseActivityEmitter } from '../../../src/server/notifications/ActivityEmitter';
@@ -26,6 +27,7 @@ class DummySocket extends EventEmitter {
describe('An UnsecureWebSocketsProtocol', (): void => {
let server: Server;
let webSocket: DummySocket;
const metadata = new RepresentationMetadata();
const source = new BaseActivityEmitter();
let protocol: UnsecureWebSocketsProtocol;
@@ -67,7 +69,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
describe('before subscribing to resources', (): 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);
});
});
@@ -83,7 +85,7 @@ describe('An UnsecureWebSocketsProtocol', (): 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.shift()).toBe('pub https://mypod.example/foo/bar');
});
@@ -100,7 +102,7 @@ describe('An UnsecureWebSocketsProtocol', (): 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.shift()).toBe('pub https://mypod.example/relative/foo');
});

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil';
@@ -21,6 +22,7 @@ describe('A ListeningActivityHandler', (): void => {
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const activity = AS.terms.Update;
const metadata = new RepresentationMetadata();
let channel: NotificationChannel;
let storage: jest.Mocked<NotificationChannelStorage>;
let emitter: ActivityEmitter;
@@ -52,12 +54,12 @@ describe('A ListeningActivityHandler', (): 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();
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(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> => {
jest.useFakeTimers();
channel.rate = 10 * 1000;
emitter.emit('changed', topic, activity);
emitter.emit('changed', topic, activity, metadata);
await flushPromises();
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(storage.update).toHaveBeenCalledTimes(1);
expect(storage.update).toHaveBeenLastCalledWith({
@@ -84,7 +86,7 @@ describe('A ListeningActivityHandler', (): void => {
channel.rate = 100000;
channel.lastEmit = Date.now();
emitter.emit('changed', topic, activity);
emitter.emit('changed', topic, activity, metadata);
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> => {
channel.startAt = Date.now() + 100000;
emitter.emit('changed', topic, activity);
emitter.emit('changed', topic, activity, metadata);
await flushPromises();
@@ -107,7 +109,7 @@ describe('A ListeningActivityHandler', (): void => {
storage.getAll.mockResolvedValue([ channel.id, channel.id ]);
notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input'));
emitter.emit('changed', topic, activity);
emitter.emit('changed', topic, activity, metadata);
await flushPromises();
@@ -119,7 +121,7 @@ describe('A ListeningActivityHandler', (): void => {
it('logs an error if something goes wrong handling the event.', async(): Promise<void> => {
storage.getAll.mockRejectedValue(new Error('bad event'));
emitter.emit('changed', topic, activity);
emitter.emit('changed', topic, activity, metadata);
await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
@@ -130,7 +132,7 @@ describe('A ListeningActivityHandler', (): void => {
it('ignores undefined channels.', async(): Promise<void> => {
storage.get.mockResolvedValue(undefined);
emitter.emit('changed', topic, activity);
emitter.emit('changed', topic, activity, metadata);
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 { DataAccessor } from '../../../src/storage/accessors/DataAccessor';
import { BasicConditions } from '../../../src/storage/BasicConditions';
import { DataAccessorBasedStore } from '../../../src/storage/DataAccessorBasedStore';
import { INTERNAL_QUADS } from '../../../src/util/ContentTypes';
import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
@@ -265,7 +264,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(RDF.terms.type, LDP.terms.Container);
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(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined();
@@ -278,7 +277,7 @@ describe('A DataAccessorBasedStore', (): void => {
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(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined();
@@ -288,6 +287,8 @@ describe('A DataAccessorBasedStore', (): void => {
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);
expect(result.get(resourceID)?.get(AS.terms.object)?.value).toEqual(generatedID.path);
});
it('can write containers.', async(): Promise<void> => {
@@ -296,7 +297,7 @@ describe('A DataAccessorBasedStore', (): void => {
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(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
const generatedID = [ ...result.keys() ].find((id): boolean => id.path !== resourceID.path)!;
expect(generatedID).toBeDefined();
@@ -317,7 +318,7 @@ describe('A DataAccessorBasedStore', (): void => {
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(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);
});
@@ -342,7 +343,7 @@ describe('A DataAccessorBasedStore', (): void => {
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(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);
});
@@ -366,7 +367,7 @@ describe('A DataAccessorBasedStore', (): void => {
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(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);
});
@@ -459,7 +460,8 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` };
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({ 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);
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());
@@ -476,7 +478,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
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({ 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(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
@@ -489,7 +491,7 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}resource` };
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({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
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());
@@ -513,7 +515,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata);
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({ path: root })?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Add);
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');
@@ -535,7 +537,7 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}a/b/resource` };
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 })?.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/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);
@@ -770,7 +772,8 @@ describe('A DataAccessorBasedStore', (): void => {
accessor.data[resourceID.path] = representation;
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({ 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(accessor.data[resourceID.path]).toBeUndefined();
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);
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(resourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Remove);
expect(result.get(auxResourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete);
expect(accessor.data[auxResourceID.path]).toBeUndefined();
});
@@ -807,7 +810,7 @@ describe('A DataAccessorBasedStore', (): void => {
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({ 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(auxResourceID)?.get(SOLID_AS.terms.activity)).toEqual(AS.terms.Delete);
expect(accessor.data[resourceID.path]).toBeUndefined();
@@ -830,7 +833,7 @@ describe('A DataAccessorBasedStore', (): void => {
logger.error = jest.fn();
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({ 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(accessor.data[resourceID.path]).toBeUndefined();
expect(accessor.data[auxResourceID.path]).toBeDefined();

View File

@@ -10,26 +10,30 @@ describe('A MonitoringStore', (): void => {
let store: MonitoringStore;
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 createdCallback: () => void;
let updatedCallback: () => void;
let deletedCallback: () => void;
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 }) ],
[ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Create }) ],
[ id, 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 }) ],
[ idNew, 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 }) ],
[ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Delete }) ],
[ id, 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 }) ],
[ idOld, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Delete }) ],
[ idNew, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Create }) ],
[ id, new RepresentationMetadata({ [SOLID_AS.activity]: AS.terms.Update }) ],
]);
beforeEach(async(): Promise<void> => {
@@ -61,98 +65,98 @@ describe('A MonitoringStore', (): void => {
it('does not fire a change event after getRepresentation.', async(): Promise<void> => {
expect(changedCallback).toHaveBeenCalledTimes(0);
await store.getRepresentation({ path: 'http://example.org/foo/bar' }, {});
await store.getRepresentation(id, {});
expect(changedCallback).toHaveBeenCalledTimes(0);
});
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);
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> => {
const result = store.addResource({ path: 'http://example.org/foo/bar/' }, {} as Representation);
const result = store.addResource(id, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
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(changedCallback).toHaveBeenCalledWith(id, AS.terms.Update, addResourceReturnMock.get(id));
expect(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Create, addResourceReturnMock.get(idNew));
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).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(updatedCallback).toHaveBeenCalledWith(id, addResourceReturnMock.get(id));
expect(deletedCallback).toHaveBeenCalledTimes(0);
});
it('calls setRepresentation directly from the source.', async(): Promise<void> => {
await expect(store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation))
await expect(store.setRepresentation(id, {} as Representation))
.resolves.toEqual(setRepresentationReturnMock);
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> => {
const result = store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation);
const result = store.setRepresentation(id, {} as Representation);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
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(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' });
expect(updatedCallback).toHaveBeenCalledWith(idNew, setRepresentationReturnMock.get(idNew));
expect(deletedCallback).toHaveBeenCalledTimes(0);
});
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);
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> => {
const result = store.deleteResource({ path: 'http://example.org/foo/bar' });
const result = store.deleteResource(id);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(2);
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(changedCallback).toHaveBeenCalledWith(id, AS.terms.Update, deleteResourceReturnMock.get(id));
expect(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Delete, deleteResourceReturnMock.get(idNew));
expect(createdCallback).toHaveBeenCalledTimes(0);
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).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> => {
await expect(store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch))
await expect(store.modifyResource(id, {} as Patch))
.resolves.toEqual(modifyResourceReturnMock);
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> => {
const result = store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch);
const result = store.modifyResource(id, {} as Patch);
expect(changedCallback).toHaveBeenCalledTimes(0);
await result;
expect(changedCallback).toHaveBeenCalledTimes(3);
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(changedCallback).toHaveBeenCalledWith(idNew, AS.terms.Create, modifyResourceReturnMock.get(idNew));
expect(changedCallback).toHaveBeenCalledWith(id, AS.terms.Update, modifyResourceReturnMock.get(id));
expect(changedCallback).toHaveBeenCalledWith(idOld, AS.terms.Delete, modifyResourceReturnMock.get(idOld));
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).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' });
expect(updatedCallback).toHaveBeenCalledWith(id, modifyResourceReturnMock.get(id));
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> => {
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).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> => {
@@ -160,7 +164,7 @@ describe('A MonitoringStore', (): void => {
[{ 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(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 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 = {
'@context': [
'https://www.w3.org/ns/activitystreams',