diff --git a/config/http/notifications/base/handler.json b/config/http/notifications/base/handler.json index a5b61be86..6f49f120e 100644 --- a/config/http/notifications/base/handler.json +++ b/config/http/notifications/base/handler.json @@ -13,6 +13,12 @@ "@type": "WaterfallHandler", "handlers": [ { "@type": "DeleteNotificationGenerator" }, + { + "@type": "AddRemoveNotificationGenerator", + "store": { + "@id": "urn:solid-server:default:ResourceStore" + } + }, { "@type": "ActivityNotificationGenerator", "store": { diff --git a/src/http/representation/RepresentationMetadata.ts b/src/http/representation/RepresentationMetadata.ts index 22c8da74c..1d6019184 100644 --- a/src/http/representation/RepresentationMetadata.ts +++ b/src/http/representation/RepresentationMetadata.ts @@ -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; 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)); diff --git a/src/index.ts b/src/index.ts index fb9767caf..04c6c35fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/server/notifications/ActivityEmitter.ts b/src/server/notifications/ActivityEmitter.ts index 5f111fb4a..1d5f6e314 100644 --- a/src/server/notifications/ActivityEmitter.ts +++ b/src/server/notifications/ActivityEmitter.ts @@ -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) => void> & - GenericEventEmitter, (target: ResourceIdentifier) => void>; + GenericEventEmitter<'changed', + (target: ResourceIdentifier, activity: VocabularyTerm, metadata: RepresentationMetadata) => void> + & + GenericEventEmitter, + (target: ResourceIdentifier, metadata: RepresentationMetadata) => void>; /** * A class implementation of {@link ActivityEmitter}. diff --git a/src/server/notifications/ListeningActivityHandler.ts b/src/server/notifications/ListeningActivityHandler.ts index 422dfda2a..c397dffab 100644 --- a/src/server/notifications/ListeningActivityHandler.ts +++ b/src/server/notifications/ListeningActivityHandler.ts @@ -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): Promise { + private async emit(topic: ResourceIdentifier, activity: VocabularyTerm, + metadata: RepresentationMetadata): Promise { 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 => { // Update the `lastEmit` value if the channel has a rate limit if (channel.rate) { diff --git a/src/server/notifications/NotificationHandler.ts b/src/server/notifications/NotificationHandler.ts index e432aa5bc..4ddeb64cb 100644 --- a/src/server/notifications/NotificationHandler.ts +++ b/src/server/notifications/NotificationHandler.ts @@ -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; + metadata?: RepresentationMetadata; } /** diff --git a/src/server/notifications/generate/AddRemoveNotificationGenerator.ts b/src/server/notifications/generate/AddRemoveNotificationGenerator.ts new file mode 100644 index 000000000..8f196c408 --- /dev/null +++ b/src/server/notifications/generate/AddRemoveNotificationGenerator.ts @@ -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 { + 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 { + 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(), + }; + } +} diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts index 68a1b7fe3..2f055afb1 100644 --- a/src/storage/DataAccessorBasedStore.ts +++ b/src/storage/DataAccessorBasedStore.ts @@ -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); + } } diff --git a/src/storage/MonitoringStore.ts b/src/storage/MonitoringStore.ts index 989b5d91c..be84dbe0d 100644 --- a/src/storage/MonitoringStore.ts +++ b/src/storage/MonitoringStore.ts @@ -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 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); } } diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index dfb5c9bb7..8ea4dd018 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -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', ); diff --git a/test/integration/WebSocketChannel2023.test.ts b/test/integration/WebSocketChannel2023.test.ts index 8ba824eca..e48202fc5 100644 --- a/test/integration/WebSocketChannel2023.test.ts +++ b/test/integration/WebSocketChannel2023.test.ts @@ -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 => { + 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((resolve): any => socket.on('message', resolve)); + await new Promise((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((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 => { const turtleChannel = ` _:id <${RDF.type}> <${notificationType}> ; diff --git a/test/unit/http/UnsecureWebSocketsProtocol.test.ts b/test/unit/http/UnsecureWebSocketsProtocol.test.ts index 33baf4d80..587ae7d74 100644 --- a/test/unit/http/UnsecureWebSocketsProtocol.test.ts +++ b/test/unit/http/UnsecureWebSocketsProtocol.test.ts @@ -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'); }); diff --git a/test/unit/server/notifications/ListeningActivityHandler.test.ts b/test/unit/server/notifications/ListeningActivityHandler.test.ts index 99fa68536..e8890e73a 100644 --- a/test/unit/server/notifications/ListeningActivityHandler.test.ts +++ b/test/unit/server/notifications/ListeningActivityHandler.test.ts @@ -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 = 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; let emitter: ActivityEmitter; @@ -52,12 +54,12 @@ describe('A ListeningActivityHandler', (): void => { }); it('calls the NotificationHandler if there is an event.', async(): Promise => { - 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 => { 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 => { 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 => { 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 => { storage.get.mockResolvedValue(undefined); - emitter.emit('changed', topic, activity); + emitter.emit('changed', topic, activity, metadata); await flushPromises(); diff --git a/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts b/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts new file mode 100644 index 000000000..bc6df8622 --- /dev/null +++ b/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts @@ -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; + let generator: AddRemoveNotificationGenerator; + + beforeEach(async(): Promise => { + 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 => { + 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 => { + 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 => { + 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(); + }); +}); diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts index eb006f9cb..b96176085 100644 --- a/test/unit/storage/DataAccessorBasedStore.test.ts +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -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 => { @@ -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 .` ]); 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(); diff --git a/test/unit/storage/MonitoringStore.test.ts b/test/unit/storage/MonitoringStore.test.ts index 0ad3e0c17..2f98b0bba 100644 --- a/test/unit/storage/MonitoringStore.test.ts +++ b/test/unit/storage/MonitoringStore.test.ts @@ -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 => { @@ -61,98 +65,98 @@ describe('A MonitoringStore', (): void => { it('does not fire a change event after getRepresentation.', async(): Promise => { 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { @@ -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); diff --git a/test/util/NotificationUtil.ts b/test/util/NotificationUtil.ts index 3bcbe2e0e..55bce5dd7 100644 --- a/test/util/NotificationUtil.ts +++ b/test/util/NotificationUtil.ts @@ -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',