mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support Add/Remove notifications on containers
This commit is contained in:
parent
9e1e65cdb9
commit
134237a80f
@ -13,6 +13,12 @@
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [
|
||||
{ "@type": "DeleteNotificationGenerator" },
|
||||
{
|
||||
"@type": "AddRemoveNotificationGenerator",
|
||||
"store": {
|
||||
"@id": "urn:solid-server:default:ResourceStore"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "ActivityNotificationGenerator",
|
||||
"store": {
|
||||
|
@ -9,7 +9,7 @@ import type { ResourceIdentifier } from './ResourceIdentifier';
|
||||
import { isResourceIdentifier } from './ResourceIdentifier';
|
||||
|
||||
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 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.
|
||||
*/
|
||||
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 ];
|
||||
for (const obj of objects) {
|
||||
forFn(predicate, toObjectTerm(obj, true));
|
||||
|
@ -311,6 +311,7 @@ export * from './server/middleware/WebSocketAdvertiser';
|
||||
|
||||
// Server/Notifications/Generate
|
||||
export * from './server/notifications/generate/ActivityNotificationGenerator';
|
||||
export * from './server/notifications/generate/AddRemoveNotificationGenerator';
|
||||
export * from './server/notifications/generate/DeleteNotificationGenerator';
|
||||
export * from './server/notifications/generate/NotificationGenerator';
|
||||
export * from './server/notifications/generate/StateNotificationGenerator';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import type { GenericEventEmitter } 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.
|
||||
*/
|
||||
export type ActivityEmitter =
|
||||
GenericEventEmitter<'changed', (target: ResourceIdentifier, activity: VocabularyTerm<typeof AS>) => void> &
|
||||
GenericEventEmitter<VocabularyValue<typeof AS>, (target: ResourceIdentifier) => void>;
|
||||
GenericEventEmitter<'changed',
|
||||
(target: ResourceIdentifier, activity: VocabularyTerm<typeof AS>, metadata: RepresentationMetadata) => void>
|
||||
&
|
||||
GenericEventEmitter<VocabularyValue<typeof AS>,
|
||||
(target: ResourceIdentifier, metadata: RepresentationMetadata) => void>;
|
||||
|
||||
/**
|
||||
* A class implementation of {@link ActivityEmitter}.
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../../logging/LogUtil';
|
||||
import { createErrorMessage } from '../../util/errors/ErrorUtil';
|
||||
@ -28,14 +29,15 @@ export class ListeningActivityHandler extends StaticHandler {
|
||||
this.storage = storage;
|
||||
this.handler = handler;
|
||||
|
||||
emitter.on('changed', (topic, activity): void => {
|
||||
this.emit(topic, activity).catch((error): void => {
|
||||
emitter.on('changed', (topic, activity, metadata): void => {
|
||||
this.emit(topic, activity, metadata).catch((error): void => {
|
||||
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);
|
||||
|
||||
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.
|
||||
// Prevent failed notification from blocking other notifications.
|
||||
this.handler.handleSafe({ channel, activity, topic })
|
||||
this.handler.handleSafe({ channel, activity, topic, metadata })
|
||||
.then((): Promise<void> => {
|
||||
// Update the `lastEmit` value if the channel has a rate limit
|
||||
if (channel.rate) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||
import type { AS, VocabularyTerm } from '../../util/Vocabularies';
|
||||
@ -7,6 +8,7 @@ export interface NotificationHandlerInput {
|
||||
topic: ResourceIdentifier;
|
||||
channel: NotificationChannel;
|
||||
activity?: VocabularyTerm<typeof AS>;
|
||||
metadata?: RepresentationMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
@ -324,7 +324,8 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
|
||||
if (!this.identifierStrategy.isRootContainer(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
|
||||
await this.updateContainerModifiedDate(container);
|
||||
@ -424,7 +425,7 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
|
||||
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 quads = await arrayifyStream(representation.data);
|
||||
metadata.addQuads(quads);
|
||||
@ -482,7 +483,7 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
|
||||
// No changes means the parent container exists and will be updated
|
||||
if (changes.size === 0) {
|
||||
this.addActivityMetadata(changes, parent, AS.terms.Update);
|
||||
this.addContainerActivity(changes, parent, true, identifier);
|
||||
}
|
||||
|
||||
// Parent container is also modified
|
||||
@ -710,4 +711,19 @@ export class DataAccessorBasedStore implements ResourceStore {
|
||||
private addActivityMetadata(map: ChangeMap, id: ResourceIdentifier, activity: NamedNode): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import type { Conditions } from './Conditions';
|
||||
import type { ResourceStore, ChangeMap } from './ResourceStore';
|
||||
|
||||
// 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
|
||||
@ -57,8 +57,8 @@ export class MonitoringStore<T extends ResourceStore = ResourceStore>
|
||||
for (const [ identifier, metadata ] of changes) {
|
||||
const activity = metadata.get(SOLID_AS.terms.activity);
|
||||
if (this.isKnownActivity(activity)) {
|
||||
this.emit('changed', identifier, activity);
|
||||
this.emit(activity.value, identifier);
|
||||
this.emit('changed', identifier, activity, metadata);
|
||||
this.emit(activity.value, identifier, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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#',
|
||||
'object',
|
||||
|
||||
'Add',
|
||||
'Create',
|
||||
'Delete',
|
||||
'Remove',
|
||||
'Update',
|
||||
);
|
||||
|
||||
|
@ -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}> ;
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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',
|
||||
|
Loading…
x
Reference in New Issue
Block a user