mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
refactor: Rename NotificationChannelInfo to NotificationChannel
This commit is contained in:
parent
61f04487a1
commit
8d31233075
@ -1,14 +1,15 @@
|
|||||||
import { getLoggerFor } from '../../logging/LogUtil';
|
import { getLoggerFor } from '../../logging/LogUtil';
|
||||||
import { createErrorMessage } from '../../util/errors/ErrorUtil';
|
import { createErrorMessage } from '../../util/errors/ErrorUtil';
|
||||||
import type { NotificationChannelInfo, NotificationChannelStorage } from './NotificationChannelStorage';
|
import type { NotificationChannel } from './NotificationChannel';
|
||||||
|
import type { NotificationChannelStorage } from './NotificationChannelStorage';
|
||||||
import type { NotificationHandler } from './NotificationHandler';
|
import type { NotificationHandler } from './NotificationHandler';
|
||||||
import { StateHandler } from './StateHandler';
|
import { StateHandler } from './StateHandler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the `state` feature by calling a {@link NotificationHandler}
|
* Handles the `state` feature by calling a {@link NotificationHandler}
|
||||||
* in case the {@link NotificationChannelInfo} has a `state` value.
|
* in case the {@link NotificationChannel} has a `state` value.
|
||||||
*
|
*
|
||||||
* Deletes the `state` parameter from the info afterwards.
|
* Deletes the `state` parameter from the channel afterwards.
|
||||||
*/
|
*/
|
||||||
export class BaseStateHandler extends StateHandler {
|
export class BaseStateHandler extends StateHandler {
|
||||||
protected readonly logger = getLoggerFor(this);
|
protected readonly logger = getLoggerFor(this);
|
||||||
@ -22,14 +23,14 @@ export class BaseStateHandler extends StateHandler {
|
|||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle({ info }: { info: NotificationChannelInfo }): Promise<void> {
|
public async handle({ channel }: { channel: NotificationChannel }): Promise<void> {
|
||||||
if (info.state) {
|
if (channel.state) {
|
||||||
const topic = { path: info.topic };
|
const topic = { path: channel.topic };
|
||||||
try {
|
try {
|
||||||
await this.handler.handleSafe({ info, topic });
|
await this.handler.handleSafe({ channel, topic });
|
||||||
// Remove the state once the relevant notification has been sent
|
// Remove the state once the relevant notification has been sent
|
||||||
delete info.state;
|
delete channel.state;
|
||||||
await this.storage.update(info);
|
await this.storage.update(channel);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.error(`Problem emitting state notification: ${createErrorMessage(error)}`);
|
this.logger.error(`Problem emitting state notification: ${createErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ export interface ComposedNotificationHandlerArgs {
|
|||||||
* Generates, serializes and emits a {@link Notification} using a {@link NotificationGenerator},
|
* Generates, serializes and emits a {@link Notification} using a {@link NotificationGenerator},
|
||||||
* {@link NotificationSerializer} and {@link NotificationEmitter}.
|
* {@link NotificationSerializer} and {@link NotificationEmitter}.
|
||||||
*
|
*
|
||||||
* Will not emit an event in case it has the same state as the notification channel info.
|
* Will not emit an event when it has the same state as the notification channel.
|
||||||
*/
|
*/
|
||||||
export class ComposedNotificationHandler extends NotificationHandler {
|
export class ComposedNotificationHandler extends NotificationHandler {
|
||||||
private readonly generator: NotificationGenerator;
|
private readonly generator: NotificationGenerator;
|
||||||
@ -35,13 +35,13 @@ export class ComposedNotificationHandler extends NotificationHandler {
|
|||||||
public async handle(input: NotificationHandlerInput): Promise<void> {
|
public async handle(input: NotificationHandlerInput): Promise<void> {
|
||||||
const notification = await this.generator.handle(input);
|
const notification = await this.generator.handle(input);
|
||||||
|
|
||||||
const { state } = input.info;
|
const { state } = input.channel;
|
||||||
// In case the state matches there is no need to send the notification
|
// In case the state matches there is no need to send the notification
|
||||||
if (typeof state === 'string' && state === notification.state) {
|
if (typeof state === 'string' && state === notification.state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const representation = await this.serializer.handleSafe({ info: input.info, notification });
|
const representation = await this.serializer.handleSafe({ channel: input.channel, notification });
|
||||||
await this.emitter.handleSafe({ info: input.info, representation });
|
await this.emitter.handleSafe({ channel: input.channel, representation });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,13 @@ import { getLoggerFor } from '../../logging/LogUtil';
|
|||||||
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
|
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
|
||||||
import { InternalServerError } from '../../util/errors/InternalServerError';
|
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||||
import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker';
|
import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker';
|
||||||
import type { NotificationChannel } from './NotificationChannel';
|
import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel';
|
||||||
import type { NotificationChannelInfo, NotificationChannelStorage } from './NotificationChannelStorage';
|
import type { NotificationChannelStorage } from './NotificationChannelStorage';
|
||||||
|
|
||||||
type StorageValue<T> = string | string[] | NotificationChannelInfo<T>;
|
type StorageValue<T> = string | string[] | NotificationChannel<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores all the {@link NotificationChannelInfo} in a {@link KeyValueStorage}.
|
* Stores all the {@link NotificationChannel} in a {@link KeyValueStorage}.
|
||||||
*
|
*
|
||||||
* Uses a {@link ReadWriteLocker} to prevent internal race conditions.
|
* Uses a {@link ReadWriteLocker} to prevent internal race conditions.
|
||||||
*/
|
*/
|
||||||
@ -25,7 +25,7 @@ export class KeyValueChannelStorage<T extends Record<string, unknown>> implement
|
|||||||
this.locker = locker;
|
this.locker = locker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public create(channel: NotificationChannel, features: T): NotificationChannelInfo<T> {
|
public create(channel: NotificationChannelJson, features: T): NotificationChannel<T> {
|
||||||
return {
|
return {
|
||||||
id: `${channel.type}:${v4()}:${channel.topic}`,
|
id: `${channel.type}:${v4()}:${channel.topic}`,
|
||||||
topic: channel.topic,
|
topic: channel.topic,
|
||||||
@ -40,92 +40,92 @@ export class KeyValueChannelStorage<T extends Record<string, unknown>> implement
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(id: string): Promise<NotificationChannelInfo<T> | undefined> {
|
public async get(id: string): Promise<NotificationChannel<T> | undefined> {
|
||||||
const info = await this.storage.get(id);
|
const channel = await this.storage.get(id);
|
||||||
if (info && this.isChannelInfo(info)) {
|
if (channel && this.isChannel(channel)) {
|
||||||
if (typeof info.endAt === 'number' && info.endAt < Date.now()) {
|
if (typeof channel.endAt === 'number' && channel.endAt < Date.now()) {
|
||||||
this.logger.info(`Notification channel ${id} has expired.`);
|
this.logger.info(`Notification channel ${id} has expired.`);
|
||||||
await this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
await this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
||||||
await this.deleteInfo(info);
|
await this.deleteChannel(channel);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return info;
|
return channel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAll(topic: ResourceIdentifier): Promise<string[]> {
|
public async getAll(topic: ResourceIdentifier): Promise<string[]> {
|
||||||
const infos = await this.storage.get(topic.path);
|
const channels = await this.storage.get(topic.path);
|
||||||
if (Array.isArray(infos)) {
|
if (Array.isArray(channels)) {
|
||||||
return infos;
|
return channels;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async add(info: NotificationChannelInfo<T>): Promise<void> {
|
public async add(channel: NotificationChannel<T>): Promise<void> {
|
||||||
const target = { path: info.topic };
|
const target = { path: channel.topic };
|
||||||
return this.locker.withWriteLock(this.getLockKey(target), async(): Promise<void> => {
|
return this.locker.withWriteLock(this.getLockKey(target), async(): Promise<void> => {
|
||||||
const infos = await this.getAll(target);
|
const channels = await this.getAll(target);
|
||||||
await this.storage.set(info.id, info);
|
await this.storage.set(channel.id, channel);
|
||||||
infos.push(info.id);
|
channels.push(channel.id);
|
||||||
await this.storage.set(info.topic, infos);
|
await this.storage.set(channel.topic, channels);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async update(info: NotificationChannelInfo<T>): Promise<void> {
|
public async update(channel: NotificationChannel<T>): Promise<void> {
|
||||||
return this.locker.withWriteLock(this.getLockKey(info.id), async(): Promise<void> => {
|
return this.locker.withWriteLock(this.getLockKey(channel.id), async(): Promise<void> => {
|
||||||
const oldInfo = await this.storage.get(info.id);
|
const oldChannel = await this.storage.get(channel.id);
|
||||||
|
|
||||||
if (oldInfo) {
|
if (oldChannel) {
|
||||||
if (!this.isChannelInfo(oldInfo)) {
|
if (!this.isChannel(oldChannel)) {
|
||||||
throw new InternalServerError(`Trying to update ${info.id} which is not a NotificationChannelInfo.`);
|
throw new InternalServerError(`Trying to update ${channel.id} which is not a NotificationChannel.`);
|
||||||
}
|
}
|
||||||
if (info.topic !== oldInfo.topic) {
|
if (channel.topic !== oldChannel.topic) {
|
||||||
throw new InternalServerError(`Trying to change the topic of a notification channel ${info.id}`);
|
throw new InternalServerError(`Trying to change the topic of a notification channel ${channel.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storage.set(info.id, info);
|
await this.storage.set(channel.id, channel);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(id: string): Promise<void> {
|
public async delete(id: string): Promise<void> {
|
||||||
return this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
return this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
||||||
const info = await this.get(id);
|
const channel = await this.get(id);
|
||||||
if (!info) {
|
if (!channel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.deleteInfo(info);
|
await this.deleteChannel(channel);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function for deleting a specific {@link NotificationChannelInfo} object.
|
* Utility function for deleting a specific {@link NotificationChannel} object.
|
||||||
* Does not create a lock on the info ID so should be wrapped in such a lock.
|
* Does not create a lock on the channel ID so should be wrapped in such a lock.
|
||||||
*/
|
*/
|
||||||
private async deleteInfo(info: NotificationChannelInfo): Promise<void> {
|
private async deleteChannel(channel: NotificationChannel): Promise<void> {
|
||||||
await this.locker.withWriteLock(this.getLockKey(info.topic), async(): Promise<void> => {
|
await this.locker.withWriteLock(this.getLockKey(channel.topic), async(): Promise<void> => {
|
||||||
const infos = await this.getAll({ path: info.topic });
|
const channels = await this.getAll({ path: channel.topic });
|
||||||
const idx = infos.indexOf(info.id);
|
const idx = channels.indexOf(channel.id);
|
||||||
// If idx < 0 we have an inconsistency
|
// If idx < 0 we have an inconsistency
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
this.logger.error(`Channel info ${info.id} was not found in the list of info targeting ${info.topic}.`);
|
this.logger.error(`Channel ${channel.id} was not found in the list of channels targeting ${channel.topic}.`);
|
||||||
this.logger.error('This should not happen and indicates a data consistency issue.');
|
this.logger.error('This should not happen and indicates a data consistency issue.');
|
||||||
} else {
|
} else {
|
||||||
infos.splice(idx, 1);
|
channels.splice(idx, 1);
|
||||||
if (infos.length > 0) {
|
if (channels.length > 0) {
|
||||||
await this.storage.set(info.topic, infos);
|
await this.storage.set(channel.topic, channels);
|
||||||
} else {
|
} else {
|
||||||
await this.storage.delete(info.topic);
|
await this.storage.delete(channel.topic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.storage.delete(info.id);
|
await this.storage.delete(channel.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private isChannelInfo(value: StorageValue<T>): value is NotificationChannelInfo<T> {
|
private isChannel(value: StorageValue<T>): value is NotificationChannel<T> {
|
||||||
return Boolean((value as NotificationChannelInfo).id);
|
return Boolean((value as NotificationChannel).id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLockKey(identifier: ResourceIdentifier | string): ResourceIdentifier {
|
private getLockKey(identifier: ResourceIdentifier | string): ResourceIdentifier {
|
||||||
|
@ -39,25 +39,25 @@ export class ListeningActivityHandler extends StaticHandler {
|
|||||||
const channelIds = await this.storage.getAll(topic);
|
const channelIds = await this.storage.getAll(topic);
|
||||||
|
|
||||||
for (const id of channelIds) {
|
for (const id of channelIds) {
|
||||||
const info = await this.storage.get(id);
|
const channel = await this.storage.get(id);
|
||||||
if (!info) {
|
if (!channel) {
|
||||||
// Notification channel has expired
|
// Notification channel has expired
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't emit if the previous notification was too recent according to the requested rate
|
// Don't emit if the previous notification was too recent according to the requested rate
|
||||||
if (info.rate && info.rate > Date.now() - info.lastEmit) {
|
if (channel.rate && channel.rate > Date.now() - channel.lastEmit) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't emit if we have not yet reached the requested starting time
|
// Don't emit if we have not yet reached the requested starting time
|
||||||
if (info.startAt && info.startAt > Date.now()) {
|
if (channel.startAt && channel.startAt > Date.now()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to wait on this to resolve before going to the next channel.
|
// No need to wait on this to resolve before going to the next channel.
|
||||||
// Prevent failed notification from blocking other notifications.
|
// Prevent failed notification from blocking other notifications.
|
||||||
this.handler.handleSafe({ info, activity, topic }).catch((error): void => {
|
this.handler.handleSafe({ channel, activity, topic }).catch((error): void => {
|
||||||
this.logger.error(`Error trying to handle notification for ${id}: ${createErrorMessage(error)}`);
|
this.logger.error(`Error trying to handle notification for ${id}: ${createErrorMessage(error)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -27,4 +27,21 @@ export const NOTIFICATION_CHANNEL_SCHEMA = object({
|
|||||||
toSeconds(parse(original)) * 1000).optional(),
|
toSeconds(parse(original)) * 1000).optional(),
|
||||||
accept: string().optional(),
|
accept: string().optional(),
|
||||||
});
|
});
|
||||||
export type NotificationChannel = InferType<typeof NOTIFICATION_CHANNEL_SCHEMA>;
|
export type NotificationChannelJson = InferType<typeof NOTIFICATION_CHANNEL_SCHEMA>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The info provided for a notification channel during a subscription.
|
||||||
|
* `features` can contain custom values relevant for a specific channel type.
|
||||||
|
*/
|
||||||
|
export type NotificationChannel<T = Record<string, unknown>> = {
|
||||||
|
id: string;
|
||||||
|
topic: string;
|
||||||
|
type: string;
|
||||||
|
startAt?: number;
|
||||||
|
endAt?: number;
|
||||||
|
accept?: string;
|
||||||
|
rate?: number;
|
||||||
|
state?: string;
|
||||||
|
lastEmit: number;
|
||||||
|
features: T;
|
||||||
|
};
|
||||||
|
@ -1,22 +1,5 @@
|
|||||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||||
import type { NotificationChannel } from './NotificationChannel';
|
import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel';
|
||||||
|
|
||||||
/**
|
|
||||||
* The info provided for a notification channel during a subscription.
|
|
||||||
* `features` can contain custom values relevant for a specific channel type.
|
|
||||||
*/
|
|
||||||
export type NotificationChannelInfo<T = Record<string, unknown>> = {
|
|
||||||
id: string;
|
|
||||||
topic: string;
|
|
||||||
type: string;
|
|
||||||
startAt?: number;
|
|
||||||
endAt?: number;
|
|
||||||
accept?: string;
|
|
||||||
rate?: number;
|
|
||||||
state?: string;
|
|
||||||
lastEmit: number;
|
|
||||||
features: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores all the information necessary to keep track of notification channels.
|
* Stores all the information necessary to keep track of notification channels.
|
||||||
@ -26,19 +9,19 @@ export type NotificationChannelInfo<T = Record<string, unknown>> = {
|
|||||||
*/
|
*/
|
||||||
export interface NotificationChannelStorage<T extends Record<string, unknown> = Record<string, unknown>> {
|
export interface NotificationChannelStorage<T extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
/**
|
/**
|
||||||
* Creates info corresponding to the given channel and features.
|
* Creates channel corresponding to the given channel and features.
|
||||||
* This does not store the generated info in the storage.
|
* This does not store the generated channel in the storage.
|
||||||
* @param channel - Notification channel to generate info of.
|
* @param channel - Notification channel to generate channel of.
|
||||||
* @param features - Features to add to the info
|
* @param features - Features to add to the channel
|
||||||
*/
|
*/
|
||||||
create: (channel: NotificationChannel, features: T) => NotificationChannelInfo<T>;
|
create: (channel: NotificationChannelJson, features: T) => NotificationChannel<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the info for the requested notification channel.
|
* Returns the channel for the requested notification channel.
|
||||||
* `undefined` if no match was found or if the notification channel expired.
|
* `undefined` if no match was found or if the notification channel expired.
|
||||||
* @param id - The identifier of the notification channel.
|
* @param id - The identifier of the notification channel.
|
||||||
*/
|
*/
|
||||||
get: (id: string) => Promise<NotificationChannelInfo<T> | undefined>;
|
get: (id: string) => Promise<NotificationChannel<T> | undefined>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the identifiers of all notification channel entries that have the given identifier as their topic.
|
* Returns the identifiers of all notification channel entries that have the given identifier as their topic.
|
||||||
@ -48,17 +31,17 @@ export interface NotificationChannelStorage<T extends Record<string, unknown> =
|
|||||||
getAll: (topic: ResourceIdentifier) => Promise<string[]>;
|
getAll: (topic: ResourceIdentifier) => Promise<string[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the given info to the storage.
|
* Adds the given channel to the storage.
|
||||||
* @param info - Info to add.
|
* @param channel - Channel to add.
|
||||||
*/
|
*/
|
||||||
add: (info: NotificationChannelInfo<T>) => Promise<void>;
|
add: (channel: NotificationChannel<T>) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the given notification channel info.
|
* Updates the given notification channel.
|
||||||
* The `id` and the `topic` can not be updated.
|
* The `id` and the `topic` can not be updated.
|
||||||
* @param info - The info to update.
|
* @param channel - The channel to update.
|
||||||
*/
|
*/
|
||||||
update: (info: NotificationChannelInfo<T>) => Promise<void>;
|
update: (channel: NotificationChannel<T>) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the given notification channel from the storage.
|
* Deletes the given notification channel from the storage.
|
||||||
|
@ -2,12 +2,11 @@ import type { InferType } from 'yup';
|
|||||||
import type { Credentials } from '../../authentication/Credentials';
|
import type { Credentials } from '../../authentication/Credentials';
|
||||||
import type { AccessMap } from '../../authorization/permissions/Permissions';
|
import type { AccessMap } from '../../authorization/permissions/Permissions';
|
||||||
import type { Representation } from '../../http/representation/Representation';
|
import type { Representation } from '../../http/representation/Representation';
|
||||||
import type { NOTIFICATION_CHANNEL_SCHEMA } from './NotificationChannel';
|
import type { NOTIFICATION_CHANNEL_SCHEMA, NotificationChannel } from './NotificationChannel';
|
||||||
import type { NotificationChannelInfo } from './NotificationChannelStorage';
|
|
||||||
|
|
||||||
export interface NotificationChannelResponse<TFeat extends Record<string, unknown> = Record<string, unknown>> {
|
export interface NotificationChannelResponse<TFeat extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
response: Representation;
|
response: Representation;
|
||||||
info: NotificationChannelInfo<TFeat>;
|
channel: NotificationChannel<TFeat>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,13 +31,13 @@ export interface NotificationChannelType<
|
|||||||
*
|
*
|
||||||
* @returns The required modes.
|
* @returns The required modes.
|
||||||
*/
|
*/
|
||||||
extractModes: (channel: InferType<TSub>) => Promise<AccessMap>;
|
extractModes: (json: InferType<TSub>) => Promise<AccessMap>;
|
||||||
/**
|
/**
|
||||||
* Registers the given notification channel.
|
* Registers the given notification channel.
|
||||||
* @param channel - The notification channel to register.
|
* @param channel - The notification channel to register.
|
||||||
* @param credentials - The credentials of the client trying to subscribe.
|
* @param credentials - The credentials of the client trying to subscribe.
|
||||||
*
|
*
|
||||||
* @returns A {@link Representation} to return as a response and the generated {@link NotificationChannelInfo}.
|
* @returns A {@link Representation} to return as a response and the generated {@link NotificationChannel}.
|
||||||
*/
|
*/
|
||||||
subscribe: (channel: InferType<TSub>, credentials: Credentials) => Promise<NotificationChannelResponse<TFeat>>;
|
subscribe: (json: InferType<TSub>, credentials: Credentials) => Promise<NotificationChannelResponse<TFeat>>;
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import type { Representation } from '../../http/representation/Representation';
|
import type { Representation } from '../../http/representation/Representation';
|
||||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||||
import type { NotificationChannelInfo } from './NotificationChannelStorage';
|
import type { NotificationChannel } from './NotificationChannel';
|
||||||
|
|
||||||
export interface NotificationEmitterInput<T = Record<string, unknown>> {
|
export interface NotificationEmitterInput<T = Record<string, unknown>> {
|
||||||
representation: Representation;
|
representation: Representation;
|
||||||
info: NotificationChannelInfo<T>;
|
channel: NotificationChannel<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits a serialized Notification to the channel defined by the info.
|
* Emits a serialized Notification to the channel defined by the channel.
|
||||||
*/
|
*/
|
||||||
export abstract class NotificationEmitter<T = Record<string, unknown>>
|
export abstract class NotificationEmitter<T = Record<string, unknown>>
|
||||||
extends AsyncHandler<NotificationEmitterInput<T>> {}
|
extends AsyncHandler<NotificationEmitterInput<T>> {}
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||||
import type { AS, VocabularyTerm } from '../../util/Vocabularies';
|
import type { AS, VocabularyTerm } from '../../util/Vocabularies';
|
||||||
import type { NotificationChannelInfo } from './NotificationChannelStorage';
|
import type { NotificationChannel } from './NotificationChannel';
|
||||||
|
|
||||||
export interface NotificationHandlerInput {
|
export interface NotificationHandlerInput {
|
||||||
topic: ResourceIdentifier;
|
topic: ResourceIdentifier;
|
||||||
info: NotificationChannelInfo;
|
channel: NotificationChannel;
|
||||||
activity?: VocabularyTerm<typeof AS>;
|
activity?: VocabularyTerm<typeof AS>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes sure an activity gets emitted to the relevant channel based on the given info.
|
* Makes sure an activity gets emitted to the relevant channel.
|
||||||
*/
|
*/
|
||||||
export abstract class NotificationHandler extends AsyncHandler<NotificationHandlerInput> {}
|
export abstract class NotificationHandler extends AsyncHandler<NotificationHandlerInput> {}
|
||||||
|
@ -13,7 +13,7 @@ import { readableToString } from '../../util/StreamUtil';
|
|||||||
import type { HttpRequest } from '../HttpRequest';
|
import type { HttpRequest } from '../HttpRequest';
|
||||||
import type { OperationHttpHandlerInput } from '../OperationHttpHandler';
|
import type { OperationHttpHandlerInput } from '../OperationHttpHandler';
|
||||||
import { OperationHttpHandler } from '../OperationHttpHandler';
|
import { OperationHttpHandler } from '../OperationHttpHandler';
|
||||||
import type { NotificationChannel } from './NotificationChannel';
|
import type { NotificationChannelJson } from './NotificationChannel';
|
||||||
import type { NotificationChannelType } from './NotificationChannelType';
|
import type { NotificationChannelType } from './NotificationChannelType';
|
||||||
|
|
||||||
export interface NotificationSubscriberArgs {
|
export interface NotificationSubscriberArgs {
|
||||||
@ -34,8 +34,8 @@ export interface NotificationSubscriberArgs {
|
|||||||
*/
|
*/
|
||||||
authorizer: Authorizer;
|
authorizer: Authorizer;
|
||||||
/**
|
/**
|
||||||
* Overrides the expiration feature of channels by making sure they always expire after the `maxDuration` value.
|
* Overrides the expiration feature of channels, by making sure they always expire after the `maxDuration` value.
|
||||||
* In case the expiration of the channel is shorter than `maxDuration` the original value will be kept.
|
* If the expiration of the channel is shorter than `maxDuration`, the original value will be kept.
|
||||||
* Value is set in minutes. 0 is infinite.
|
* Value is set in minutes. 0 is infinite.
|
||||||
*/
|
*/
|
||||||
maxDuration?: number;
|
maxDuration?: number;
|
||||||
@ -70,7 +70,7 @@ export class NotificationSubscriber extends OperationHttpHandler {
|
|||||||
throw new UnsupportedMediaTypeHttpError('Subscribe bodies need to be application/ld+json.');
|
throw new UnsupportedMediaTypeHttpError('Subscribe bodies need to be application/ld+json.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel: NotificationChannel;
|
let channel: NotificationChannelJson;
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(await readableToString(operation.body.data));
|
const json = JSON.parse(await readableToString(operation.body.data));
|
||||||
channel = await this.channelType.schema.validate(json);
|
channel = await this.channelType.schema.validate(json);
|
||||||
@ -93,7 +93,7 @@ export class NotificationSubscriber extends OperationHttpHandler {
|
|||||||
return new OkResponseDescription(response.metadata, response.data);
|
return new OkResponseDescription(response.metadata, response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async authorize(request: HttpRequest, channel: NotificationChannel): Promise<Credentials> {
|
private async authorize(request: HttpRequest, channel: NotificationChannelJson): Promise<Credentials> {
|
||||||
const credentials = await this.credentialsExtractor.handleSafe(request);
|
const credentials = await this.credentialsExtractor.handleSafe(request);
|
||||||
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
|
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||||
import type { NotificationChannelInfo } from './NotificationChannelStorage';
|
import type { NotificationChannel } from './NotificationChannel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the `state` feature of notifications.
|
* Handles the `state` feature of notifications.
|
||||||
@ -8,4 +8,4 @@ import type { NotificationChannelInfo } from './NotificationChannelStorage';
|
|||||||
*
|
*
|
||||||
* Implementations of this class should handle all channels and filter out those that need a `state` notification.
|
* Implementations of this class should handle all channels and filter out those that need a `state` notification.
|
||||||
*/
|
*/
|
||||||
export abstract class StateHandler extends AsyncHandler<{ info: NotificationChannelInfo }> {}
|
export abstract class StateHandler extends AsyncHandler<{ channel: NotificationChannel }> {}
|
||||||
|
@ -16,7 +16,7 @@ export class TypedNotificationHandler extends NotificationHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async canHandle(input: NotificationHandlerInput): Promise<void> {
|
public async canHandle(input: NotificationHandlerInput): Promise<void> {
|
||||||
if (input.info.type !== this.type) {
|
if (input.channel.type !== this.type) {
|
||||||
throw new NotImplementedHttpError(`Only ${this.type} notification channels are supported.`);
|
throw new NotImplementedHttpError(`Only ${this.type} notification channels are supported.`);
|
||||||
}
|
}
|
||||||
await this.source.canHandle(input);
|
await this.source.canHandle(input);
|
||||||
|
@ -34,8 +34,8 @@ export class WebHookEmitter extends NotificationEmitter<WebHookFeatures> {
|
|||||||
this.expiration = expiration * 60 * 1000;
|
this.expiration = expiration * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle({ info, representation }: NotificationEmitterInput<WebHookFeatures>): Promise<void> {
|
public async handle({ channel, representation }: NotificationEmitterInput<WebHookFeatures>): Promise<void> {
|
||||||
this.logger.debug(`Emitting WebHook notification with target ${info.features.target}`);
|
this.logger.debug(`Emitting WebHook notification with target ${channel.features.target}`);
|
||||||
|
|
||||||
const privateKey = await this.jwkGenerator.getPrivateKey();
|
const privateKey = await this.jwkGenerator.getPrivateKey();
|
||||||
const publicKey = await this.jwkGenerator.getPublicKey();
|
const publicKey = await this.jwkGenerator.getPublicKey();
|
||||||
@ -66,14 +66,14 @@ export class WebHookEmitter extends NotificationEmitter<WebHookFeatures> {
|
|||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
|
||||||
const dpopProof = await new SignJWT({
|
const dpopProof = await new SignJWT({
|
||||||
htu: info.features.target,
|
htu: channel.features.target,
|
||||||
htm: 'POST',
|
htm: 'POST',
|
||||||
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
|
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
|
||||||
.setIssuedAt(time)
|
.setIssuedAt(time)
|
||||||
.setJti(v4())
|
.setJti(v4())
|
||||||
.sign(privateKeyObject);
|
.sign(privateKeyObject);
|
||||||
|
|
||||||
const response = await fetch(info.features.target, {
|
const response = await fetch(channel.features.target, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': representation.metadata.contentType!,
|
'content-type': representation.metadata.contentType!,
|
||||||
@ -83,7 +83,7 @@ export class WebHookEmitter extends NotificationEmitter<WebHookFeatures> {
|
|||||||
body: await readableToString(representation.data),
|
body: await readableToString(representation.data),
|
||||||
});
|
});
|
||||||
if (response.status >= 400) {
|
if (response.status >= 400) {
|
||||||
this.logger.error(`There was an issue emitting a WebHook notification with target ${info.features.target}: ${
|
this.logger.error(`There was an issue emitting a WebHook notification with target ${channel.features.target}: ${
|
||||||
await response.text()}`);
|
await response.text()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,11 +52,11 @@ export class WebHookSubscription2021 implements NotificationChannelType<typeof s
|
|||||||
this.stateHandler = stateHandler;
|
this.stateHandler = stateHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async extractModes(channel: InferType<typeof schema>): Promise<AccessMap> {
|
public async extractModes(json: InferType<typeof schema>): Promise<AccessMap> {
|
||||||
return new IdentifierSetMultiMap<AccessMode>([[{ path: channel.topic }, AccessMode.read ]]);
|
return new IdentifierSetMultiMap<AccessMode>([[{ path: json.topic }, AccessMode.read ]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async subscribe(channel: InferType<typeof schema>, credentials: Credentials):
|
public async subscribe(json: InferType<typeof schema>, credentials: Credentials):
|
||||||
Promise<NotificationChannelResponse<WebHookFeatures>> {
|
Promise<NotificationChannelResponse<WebHookFeatures>> {
|
||||||
const webId = credentials.agent?.webId;
|
const webId = credentials.agent?.webId;
|
||||||
|
|
||||||
@ -66,15 +66,15 @@ export class WebHookSubscription2021 implements NotificationChannelType<typeof s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = this.storage.create(channel, { target: channel.target, webId });
|
const channel = this.storage.create(json, { target: json.target, webId });
|
||||||
await this.storage.add(info);
|
await this.storage.add(channel);
|
||||||
|
|
||||||
const jsonld = {
|
const jsonld = {
|
||||||
'@context': [ CONTEXT_NOTIFICATION ],
|
'@context': [ CONTEXT_NOTIFICATION ],
|
||||||
type: this.type,
|
type: this.type,
|
||||||
target: channel.target,
|
target: json.target,
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, info.id),
|
unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, channel.id),
|
||||||
};
|
};
|
||||||
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
|
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
|
||||||
|
|
||||||
@ -82,11 +82,11 @@ export class WebHookSubscription2021 implements NotificationChannelType<typeof s
|
|||||||
// right after we send the response for subscribing.
|
// right after we send the response for subscribing.
|
||||||
// We do this by waiting for the response to be closed.
|
// We do this by waiting for the response to be closed.
|
||||||
endOfStream(response.data)
|
endOfStream(response.data)
|
||||||
.then((): Promise<void> => this.stateHandler.handleSafe({ info }))
|
.then((): Promise<void> => this.stateHandler.handleSafe({ channel }))
|
||||||
.catch((error): void => {
|
.catch((error): void => {
|
||||||
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
|
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { response, info };
|
return { response, channel };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,13 +28,13 @@ export class WebHookUnsubscriber extends OperationHttpHandler {
|
|||||||
|
|
||||||
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
|
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
|
||||||
const id = parseWebHookUnsubscribeUrl(operation.target.path);
|
const id = parseWebHookUnsubscribeUrl(operation.target.path);
|
||||||
const info = await this.storage.get(id);
|
const channel = await this.storage.get(id);
|
||||||
if (!info) {
|
if (!channel) {
|
||||||
throw new NotFoundHttpError();
|
throw new NotFoundHttpError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = await this.credentialsExtractor.handleSafe(request);
|
const credentials = await this.credentialsExtractor.handleSafe(request);
|
||||||
if (info.features.webId !== credentials.agent?.webId) {
|
if (channel.features.webId !== credentials.agent?.webId) {
|
||||||
throw new ForbiddenHttpError();
|
throw new ForbiddenHttpError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,9 +21,9 @@ export class WebSocket2021Emitter extends NotificationEmitter {
|
|||||||
this.socketMap = socketMap;
|
this.socketMap = socketMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle({ info, representation }: NotificationEmitterInput): Promise<void> {
|
public async handle({ channel, representation }: NotificationEmitterInput): Promise<void> {
|
||||||
// Called as a NotificationEmitter: emit the notification
|
// Called as a NotificationEmitter: emit the notification
|
||||||
const webSockets = this.socketMap.get(info.id);
|
const webSockets = this.socketMap.get(channel.id);
|
||||||
if (webSockets) {
|
if (webSockets) {
|
||||||
const data = await readableToString(representation.data);
|
const data = await readableToString(representation.data);
|
||||||
for (const webSocket of webSockets) {
|
for (const webSocket of webSockets) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||||
import type { NotificationChannelInfo } from '../NotificationChannelStorage';
|
import type { NotificationChannel } from '../NotificationChannel';
|
||||||
|
|
||||||
export interface WebSocket2021HandlerInput {
|
export interface WebSocket2021HandlerInput {
|
||||||
info: NotificationChannelInfo;
|
channel: NotificationChannel;
|
||||||
webSocket: WebSocket;
|
webSocket: WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,16 +38,16 @@ export class WebSocket2021Listener extends WebSocketServerConfigurator {
|
|||||||
return webSocket.close();
|
return webSocket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await this.storage.get(id);
|
const channel = await this.storage.get(id);
|
||||||
|
|
||||||
if (!info) {
|
if (!channel) {
|
||||||
// Info not being there implies it has expired
|
// Channel not being there implies it has expired
|
||||||
webSocket.send(`Notification channel has expired`);
|
webSocket.send(`Notification channel has expired`);
|
||||||
return webSocket.close();
|
return webSocket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Accepted WebSocket connection listening to changes on ${info.topic}`);
|
this.logger.info(`Accepted WebSocket connection listening to changes on ${channel.topic}`);
|
||||||
|
|
||||||
await this.handler.handleSafe({ info, webSocket });
|
await this.handler.handleSafe({ channel, webSocket });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,10 +34,10 @@ export class WebSocket2021Storer extends WebSocket2021Handler {
|
|||||||
timer.unref();
|
timer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle({ webSocket, info }: WebSocket2021HandlerInput): Promise<void> {
|
public async handle({ webSocket, channel }: WebSocket2021HandlerInput): Promise<void> {
|
||||||
this.socketMap.add(info.id, webSocket);
|
this.socketMap.add(channel.id, webSocket);
|
||||||
webSocket.on('error', (): boolean => this.socketMap.deleteEntry(info.id, webSocket));
|
webSocket.on('error', (): boolean => this.socketMap.deleteEntry(channel.id, webSocket));
|
||||||
webSocket.on('close', (): boolean => this.socketMap.deleteEntry(info.id, webSocket));
|
webSocket.on('close', (): boolean => this.socketMap.deleteEntry(channel.id, webSocket));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -7,7 +7,7 @@ import { getLoggerFor } from '../../../logging/LogUtil';
|
|||||||
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
|
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
|
||||||
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap';
|
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap';
|
||||||
import { CONTEXT_NOTIFICATION } from '../Notification';
|
import { CONTEXT_NOTIFICATION } from '../Notification';
|
||||||
import type { NotificationChannel } from '../NotificationChannel';
|
import type { NotificationChannelJson } from '../NotificationChannel';
|
||||||
import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel';
|
import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel';
|
||||||
import type { NotificationChannelStorage } from '../NotificationChannelStorage';
|
import type { NotificationChannelStorage } from '../NotificationChannelStorage';
|
||||||
import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType';
|
import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType';
|
||||||
@ -38,21 +38,21 @@ export class WebSocketSubscription2021 implements NotificationChannelType<typeof
|
|||||||
this.path = route.getPath();
|
this.path = route.getPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async extractModes(channel: NotificationChannel): Promise<AccessMap> {
|
public async extractModes(json: NotificationChannelJson): Promise<AccessMap> {
|
||||||
return new IdentifierSetMultiMap<AccessMode>([[{ path: channel.topic }, AccessMode.read ]]);
|
return new IdentifierSetMultiMap<AccessMode>([[{ path: json.topic }, AccessMode.read ]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async subscribe(channel: NotificationChannel): Promise<NotificationChannelResponse> {
|
public async subscribe(json: NotificationChannelJson): Promise<NotificationChannelResponse> {
|
||||||
const info = this.storage.create(channel, {});
|
const channel = this.storage.create(json, {});
|
||||||
await this.storage.add(info);
|
await this.storage.add(channel);
|
||||||
|
|
||||||
const jsonld = {
|
const jsonld = {
|
||||||
'@context': [ CONTEXT_NOTIFICATION ],
|
'@context': [ CONTEXT_NOTIFICATION ],
|
||||||
type: this.type,
|
type: this.type,
|
||||||
source: generateWebSocketUrl(this.path, info.id),
|
source: generateWebSocketUrl(this.path, channel.id),
|
||||||
};
|
};
|
||||||
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
|
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
|
||||||
|
|
||||||
return { response, info };
|
return { response, channel };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ export class ConvertingNotificationSerializer extends NotificationSerializer {
|
|||||||
public async handle(input: NotificationSerializerInput): Promise<Representation> {
|
public async handle(input: NotificationSerializerInput): Promise<Representation> {
|
||||||
const representation = await this.source.handle(input);
|
const representation = await this.source.handle(input);
|
||||||
|
|
||||||
const type = input.info.accept;
|
const type = input.channel.accept;
|
||||||
|
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return representation;
|
return representation;
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import type { Representation } from '../../../http/representation/Representation';
|
import type { Representation } from '../../../http/representation/Representation';
|
||||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||||
import type { Notification } from '../Notification';
|
import type { Notification } from '../Notification';
|
||||||
import type { NotificationChannelInfo } from '../NotificationChannelStorage';
|
import type { NotificationChannel } from '../NotificationChannel';
|
||||||
|
|
||||||
export interface NotificationSerializerInput {
|
export interface NotificationSerializerInput {
|
||||||
notification: Notification;
|
notification: Notification;
|
||||||
info: NotificationChannelInfo;
|
channel: NotificationChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a {@link Notification} into a {@link Representation} that can be transmitted.
|
* Converts a {@link Notification} into a {@link Representation} that can be transmitted.
|
||||||
*
|
*
|
||||||
* The reason this is a separate class in between a generator and emitter,
|
* This is a separate class between a generator and emitter,
|
||||||
* is so a specific notification channel type can add extra metadata to the Representation if needed.
|
* so that a specific notification channel type can add extra metadata to the Representation if needed.
|
||||||
*/
|
*/
|
||||||
export abstract class NotificationSerializer extends AsyncHandler<NotificationSerializerInput, Representation> { }
|
export abstract class NotificationSerializer extends AsyncHandler<NotificationSerializerInput, Representation> { }
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import { BaseStateHandler } from '../../../../src/server/notifications/BaseStateHandler';
|
import { BaseStateHandler } from '../../../../src/server/notifications/BaseStateHandler';
|
||||||
|
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||||
import type {
|
import type {
|
||||||
NotificationChannelInfo,
|
|
||||||
NotificationChannelStorage,
|
NotificationChannelStorage,
|
||||||
} from '../../../../src/server/notifications/NotificationChannelStorage';
|
} from '../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
||||||
|
|
||||||
describe('A BaseStateHandler', (): void => {
|
describe('A BaseStateHandler', (): void => {
|
||||||
let info: NotificationChannelInfo;
|
let channel: NotificationChannel;
|
||||||
let notificationHandler: jest.Mocked<NotificationHandler>;
|
let notificationHandler: jest.Mocked<NotificationHandler>;
|
||||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||||
let handler: BaseStateHandler;
|
let handler: BaseStateHandler;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
info = {
|
channel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://exa mple.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
features: {},
|
features: {},
|
||||||
lastEmit: 0,
|
lastEmit: 0,
|
||||||
@ -33,21 +33,21 @@ describe('A BaseStateHandler', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls the handler if there is a trigger.', async(): Promise<void> => {
|
it('calls the handler if there is a trigger.', async(): Promise<void> => {
|
||||||
await expect(handler.handleSafe({ info })).resolves.toBeUndefined();
|
await expect(handler.handleSafe({ channel })).resolves.toBeUndefined();
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
// Note that jest stores a reference to the input object so we can't see that the state value was still there
|
// Note that jest stores a reference to the input object so we can't see that the state value was still there
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: info.topic }, info });
|
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: channel.topic }, channel });
|
||||||
expect(info.state).toBeUndefined();
|
expect(channel.state).toBeUndefined();
|
||||||
expect(storage.update).toHaveBeenCalledTimes(1);
|
expect(storage.update).toHaveBeenCalledTimes(1);
|
||||||
expect(storage.update).toHaveBeenLastCalledWith(info);
|
expect(storage.update).toHaveBeenLastCalledWith(channel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not delete the state parameter if something goes wrong.', async(): Promise<void> => {
|
it('does not delete the state parameter if something goes wrong.', async(): Promise<void> => {
|
||||||
notificationHandler.handleSafe.mockRejectedValue(new Error('bad input'));
|
notificationHandler.handleSafe.mockRejectedValue(new Error('bad input'));
|
||||||
await expect(handler.handleSafe({ info })).resolves.toBeUndefined();
|
await expect(handler.handleSafe({ channel })).resolves.toBeUndefined();
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: info.topic }, info });
|
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: channel.topic }, channel });
|
||||||
expect(info.state).toBe('123');
|
expect(channel.state).toBe('123');
|
||||||
expect(storage.update).toHaveBeenCalledTimes(0);
|
expect(storage.update).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,7 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/Res
|
|||||||
import { ComposedNotificationHandler } from '../../../../src/server/notifications/ComposedNotificationHandler';
|
import { ComposedNotificationHandler } from '../../../../src/server/notifications/ComposedNotificationHandler';
|
||||||
import type { NotificationGenerator } from '../../../../src/server/notifications/generate/NotificationGenerator';
|
import type { NotificationGenerator } from '../../../../src/server/notifications/generate/NotificationGenerator';
|
||||||
import type { Notification } from '../../../../src/server/notifications/Notification';
|
import type { Notification } from '../../../../src/server/notifications/Notification';
|
||||||
import type { NotificationChannelInfo } from '../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { NotificationEmitter } from '../../../../src/server/notifications/NotificationEmitter';
|
import type { NotificationEmitter } from '../../../../src/server/notifications/NotificationEmitter';
|
||||||
import type { NotificationSerializer } from '../../../../src/server/notifications/serialize/NotificationSerializer';
|
import type { NotificationSerializer } from '../../../../src/server/notifications/serialize/NotificationSerializer';
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ describe('A ComposedNotificationHandler', (): void => {
|
|||||||
published: '123',
|
published: '123',
|
||||||
state: '123',
|
state: '123',
|
||||||
};
|
};
|
||||||
let info: NotificationChannelInfo;
|
let channel: NotificationChannel;
|
||||||
const representation = new BasicRepresentation();
|
const representation = new BasicRepresentation();
|
||||||
let generator: jest.Mocked<NotificationGenerator>;
|
let generator: jest.Mocked<NotificationGenerator>;
|
||||||
let serializer: jest.Mocked<NotificationSerializer>;
|
let serializer: jest.Mocked<NotificationSerializer>;
|
||||||
@ -28,7 +28,7 @@ describe('A ComposedNotificationHandler', (): void => {
|
|||||||
let handler: ComposedNotificationHandler;
|
let handler: ComposedNotificationHandler;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
info = {
|
channel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -53,26 +53,26 @@ describe('A ComposedNotificationHandler', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can only handle input supported by the generator.', async(): Promise<void> => {
|
it('can only handle input supported by the generator.', async(): Promise<void> => {
|
||||||
await expect(handler.canHandle({ info, topic })).resolves.toBeUndefined();
|
await expect(handler.canHandle({ channel, topic })).resolves.toBeUndefined();
|
||||||
generator.canHandle.mockRejectedValue(new Error('bad input'));
|
generator.canHandle.mockRejectedValue(new Error('bad input'));
|
||||||
await expect(handler.canHandle({ info, topic })).rejects.toThrow('bad input');
|
await expect(handler.canHandle({ channel, topic })).rejects.toThrow('bad input');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls the three wrapped classes in order.', async(): Promise<void> => {
|
it('calls the three wrapped classes in order.', async(): Promise<void> => {
|
||||||
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
|
await expect(handler.handle({ channel, topic })).resolves.toBeUndefined();
|
||||||
expect(generator.handle).toHaveBeenCalledTimes(1);
|
expect(generator.handle).toHaveBeenCalledTimes(1);
|
||||||
expect(generator.handle).toHaveBeenLastCalledWith({ info, topic });
|
expect(generator.handle).toHaveBeenLastCalledWith({ channel, topic });
|
||||||
expect(serializer.handleSafe).toHaveBeenCalledTimes(1);
|
expect(serializer.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(serializer.handleSafe).toHaveBeenLastCalledWith({ info, notification });
|
expect(serializer.handleSafe).toHaveBeenLastCalledWith({ channel, notification });
|
||||||
expect(emitter.handleSafe).toHaveBeenCalledTimes(1);
|
expect(emitter.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(emitter.handleSafe).toHaveBeenLastCalledWith({ info, representation });
|
expect(emitter.handleSafe).toHaveBeenLastCalledWith({ channel, representation });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not emit the notification if its state matches the info state.', async(): Promise<void> => {
|
it('does not emit the notification if its state matches the channel state.', async(): Promise<void> => {
|
||||||
info.state = notification.state;
|
channel.state = notification.state;
|
||||||
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
|
await expect(handler.handle({ channel, topic })).resolves.toBeUndefined();
|
||||||
expect(generator.handle).toHaveBeenCalledTimes(1);
|
expect(generator.handle).toHaveBeenCalledTimes(1);
|
||||||
expect(generator.handle).toHaveBeenLastCalledWith({ info, topic });
|
expect(generator.handle).toHaveBeenLastCalledWith({ channel, topic });
|
||||||
expect(serializer.handleSafe).toHaveBeenCalledTimes(0);
|
expect(serializer.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
expect(emitter.handleSafe).toHaveBeenCalledTimes(0);
|
expect(emitter.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
@ -3,8 +3,10 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/Res
|
|||||||
import type { Logger } from '../../../../src/logging/Logger';
|
import type { Logger } from '../../../../src/logging/Logger';
|
||||||
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
||||||
import { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage';
|
import { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage';
|
||||||
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
import type {
|
||||||
import type { NotificationChannelInfo } from '../../../../src/server/notifications/NotificationChannelStorage';
|
NotificationChannel,
|
||||||
|
NotificationChannelJson,
|
||||||
|
} from '../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
||||||
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
|
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
|
||||||
import resetAllMocks = jest.resetAllMocks;
|
import resetAllMocks = jest.resetAllMocks;
|
||||||
@ -19,13 +21,13 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
const logger = getLoggerFor('mock');
|
const logger = getLoggerFor('mock');
|
||||||
const topic = 'http://example.com/foo';
|
const topic = 'http://example.com/foo';
|
||||||
const identifier = { path: topic };
|
const identifier = { path: topic };
|
||||||
const channel = {
|
const json = {
|
||||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||||
type: 'WebSocketSubscription2021',
|
type: 'WebSocketSubscription2021',
|
||||||
topic,
|
topic,
|
||||||
} as NotificationChannel;
|
} as NotificationChannelJson;
|
||||||
const features = { aa: 'bb' };
|
const features = { aa: 'bb' };
|
||||||
let info: NotificationChannelInfo<Record<string, string>>;
|
let channel: NotificationChannel<Record<string, string>>;
|
||||||
let internalMap: Map<string, any>;
|
let internalMap: Map<string, any>;
|
||||||
let internalStorage: KeyValueStorage<string, any>;
|
let internalStorage: KeyValueStorage<string, any>;
|
||||||
let locker: ReadWriteLocker;
|
let locker: ReadWriteLocker;
|
||||||
@ -33,7 +35,7 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
resetAllMocks();
|
resetAllMocks();
|
||||||
info = {
|
channel = {
|
||||||
id: `WebSocketSubscription2021:${v4()}:http://example.com/foo`,
|
id: `WebSocketSubscription2021:${v4()}:http://example.com/foo`,
|
||||||
topic,
|
topic,
|
||||||
type: 'WebSocketSubscription2021',
|
type: 'WebSocketSubscription2021',
|
||||||
@ -54,8 +56,8 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('#create', (): void => {
|
describe('#create', (): void => {
|
||||||
it('creates info based on a notification channel.', async(): Promise<void> => {
|
it('creates channel based on a notification channel.', async(): Promise<void> => {
|
||||||
expect(storage.create(channel, features)).toEqual(info);
|
expect(storage.create(json, features)).toEqual(channel);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,15 +66,15 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
await expect(storage.get('notexists')).resolves.toBeUndefined();
|
await expect(storage.get('notexists')).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the matching info.', async(): Promise<void> => {
|
it('returns the matching channel.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
await expect(storage.get(info.id)).resolves.toEqual(info);
|
await expect(storage.get(channel.id)).resolves.toEqual(channel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes expired info.', async(): Promise<void> => {
|
it('deletes expired channel.', async(): Promise<void> => {
|
||||||
info.endAt = 0;
|
channel.endAt = 0;
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
await expect(storage.get(info.id)).resolves.toBeUndefined();
|
await expect(storage.get(channel.id)).resolves.toBeUndefined();
|
||||||
expect(internalMap.size).toBe(0);
|
expect(internalMap.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -82,93 +84,93 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
await expect(storage.getAll(identifier)).resolves.toEqual([]);
|
await expect(storage.getAll(identifier)).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the identifiers of all the matching infos.', async(): Promise<void> => {
|
it('returns the identifiers of all the matching channels.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
await expect(storage.getAll(identifier)).resolves.toEqual([ info.id ]);
|
await expect(storage.getAll(identifier)).resolves.toEqual([ channel.id ]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#add', (): void => {
|
describe('#add', (): void => {
|
||||||
it('adds the info and adds its id to the topic collection.', async(): Promise<void> => {
|
it('adds the channel and adds its id to the topic collection.', async(): Promise<void> => {
|
||||||
await expect(storage.add(info)).resolves.toBeUndefined();
|
await expect(storage.add(channel)).resolves.toBeUndefined();
|
||||||
expect(internalMap.size).toBe(2);
|
expect(internalMap.size).toBe(2);
|
||||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||||
[ info.id ],
|
[ channel.id ],
|
||||||
info,
|
channel,
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#update', (): void => {
|
describe('#update', (): void => {
|
||||||
it('changes the info.', async(): Promise<void> => {
|
it('changes the channel.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
const newInfo = {
|
const newChannel = {
|
||||||
...info,
|
...channel,
|
||||||
state: '123456',
|
state: '123456',
|
||||||
};
|
};
|
||||||
await expect(storage.update(newInfo)).resolves.toBeUndefined();
|
await expect(storage.update(newChannel)).resolves.toBeUndefined();
|
||||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||||
[ info.id ],
|
[ channel.id ],
|
||||||
newInfo,
|
newChannel,
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects update requests that change the topic.', async(): Promise<void> => {
|
it('rejects update requests that change the topic.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
const newInfo = {
|
const newChannel = {
|
||||||
...info,
|
...channel,
|
||||||
topic: 'http://example.com/other',
|
topic: 'http://example.com/other',
|
||||||
};
|
};
|
||||||
await expect(storage.update(newInfo)).rejects
|
await expect(storage.update(newChannel)).rejects
|
||||||
.toThrow(`Trying to change the topic of a notification channel ${info.id}`);
|
.toThrow(`Trying to change the topic of a notification channel ${channel.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects update request targeting a non-info value.', async(): Promise<void> => {
|
it('rejects update request targeting a non-channel value.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
// Looking for the key so this test doesn't depend on the internal keys used
|
// Looking for the key so this test doesn't depend on the internal keys used
|
||||||
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
||||||
const newInfo = {
|
const newChannel = {
|
||||||
...info,
|
...channel,
|
||||||
id,
|
id,
|
||||||
};
|
};
|
||||||
await expect(storage.update(newInfo)).rejects
|
await expect(storage.update(newChannel)).rejects
|
||||||
.toThrow(`Trying to update ${id} which is not a NotificationChannelInfo.`);
|
.toThrow(`Trying to update ${id} which is not a NotificationChannel.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#delete', (): void => {
|
describe('#delete', (): void => {
|
||||||
it('removes the info and its reference.', async(): Promise<void> => {
|
it('removes the channel and its reference.', async(): Promise<void> => {
|
||||||
const info2 = {
|
const channel2 = {
|
||||||
...info,
|
...channel,
|
||||||
id: 'differentId',
|
id: 'differentId',
|
||||||
};
|
};
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
await storage.add(info2);
|
await storage.add(channel2);
|
||||||
expect(internalMap.size).toBe(3);
|
expect(internalMap.size).toBe(3);
|
||||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
||||||
expect(internalMap.size).toBe(2);
|
expect(internalMap.size).toBe(2);
|
||||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||||
[ info2.id ],
|
[ channel2.id ],
|
||||||
info2,
|
channel2,
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes the references for an identifier if the array is empty.', async(): Promise<void> => {
|
it('removes the references for an identifier if the array is empty.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
||||||
expect(internalMap.size).toBe(0);
|
expect(internalMap.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing if the target does not exist.', async(): Promise<void> => {
|
it('does nothing if the target does not exist.', async(): Promise<void> => {
|
||||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an error if the target can not be found in the list of references.', async(): Promise<void> => {
|
it('logs an error if the target can not be found in the list of references.', async(): Promise<void> => {
|
||||||
await storage.add(info);
|
await storage.add(channel);
|
||||||
// Looking for the key so this test doesn't depend on the internal keys used
|
// Looking for the key so this test doesn't depend on the internal keys used
|
||||||
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
||||||
internalMap.set(id, []);
|
internalMap.set(id, []);
|
||||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
||||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -4,8 +4,8 @@ import type { Logger } from '../../../../src/logging/Logger';
|
|||||||
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
||||||
import type { ActivityEmitter } from '../../../../src/server/notifications/ActivityEmitter';
|
import type { ActivityEmitter } from '../../../../src/server/notifications/ActivityEmitter';
|
||||||
import { ListeningActivityHandler } from '../../../../src/server/notifications/ListeningActivityHandler';
|
import { ListeningActivityHandler } from '../../../../src/server/notifications/ListeningActivityHandler';
|
||||||
|
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||||
import type {
|
import type {
|
||||||
NotificationChannelInfo,
|
|
||||||
NotificationChannelStorage,
|
NotificationChannelStorage,
|
||||||
} from '../../../../src/server/notifications/NotificationChannelStorage';
|
} from '../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
||||||
@ -21,7 +21,7 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
|
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
|
||||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||||
const activity = AS.terms.Update;
|
const activity = AS.terms.Update;
|
||||||
let info: NotificationChannelInfo;
|
let channel: NotificationChannel;
|
||||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||||
let emitter: ActivityEmitter;
|
let emitter: ActivityEmitter;
|
||||||
let notificationHandler: jest.Mocked<NotificationHandler>;
|
let notificationHandler: jest.Mocked<NotificationHandler>;
|
||||||
@ -29,7 +29,7 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
info = {
|
channel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -38,8 +38,8 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
storage = {
|
storage = {
|
||||||
getAll: jest.fn().mockResolvedValue([ info.id ]),
|
getAll: jest.fn().mockResolvedValue([ channel.id ]),
|
||||||
get: jest.fn().mockResolvedValue(info),
|
get: jest.fn().mockResolvedValue(channel),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
emitter = new EventEmitter() as any;
|
emitter = new EventEmitter() as any;
|
||||||
@ -58,13 +58,13 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ info, activity, topic });
|
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ channel, activity, topic });
|
||||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not emit an event on channels if their rate does not yet allow it.', async(): Promise<void> => {
|
it('does not emit an event on channels if their rate does not yet allow it.', async(): Promise<void> => {
|
||||||
info.rate = 100000;
|
channel.rate = 100000;
|
||||||
info.lastEmit = Date.now();
|
channel.lastEmit = Date.now();
|
||||||
|
|
||||||
emitter.emit('changed', topic, activity);
|
emitter.emit('changed', topic, activity);
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not emit an event on channels if their start time has not been reached.', async(): Promise<void> => {
|
it('does not emit an event on channels if their start time has not been reached.', async(): Promise<void> => {
|
||||||
info.startAt = Date.now() + 100000;
|
channel.startAt = Date.now() + 100000;
|
||||||
|
|
||||||
emitter.emit('changed', topic, activity);
|
emitter.emit('changed', topic, activity);
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not stop if one channel causes an error.', async(): Promise<void> => {
|
it('does not stop if one channel causes an error.', async(): Promise<void> => {
|
||||||
storage.getAll.mockResolvedValue([ info.id, info.id ]);
|
storage.getAll.mockResolvedValue([ channel.id, channel.id ]);
|
||||||
notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input'));
|
notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input'));
|
||||||
|
|
||||||
emitter.emit('changed', topic, activity);
|
emitter.emit('changed', topic, activity);
|
||||||
@ -95,7 +95,7 @@ describe('A ListeningActivityHandler', (): void => {
|
|||||||
|
|
||||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(2);
|
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(2);
|
||||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
expect(logger.error).toHaveBeenLastCalledWith(`Error trying to handle notification for ${info.id}: bad input`);
|
expect(logger.error).toHaveBeenLastCalledWith(`Error trying to handle notification for ${channel.id}: bad input`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an error if something goes wrong handling the event.', async(): Promise<void> => {
|
it('logs an error if something goes wrong handling the event.', async(): Promise<void> => {
|
||||||
|
@ -47,7 +47,7 @@ describe('A NotificationSubscriber', (): void => {
|
|||||||
schema: NOTIFICATION_CHANNEL_SCHEMA,
|
schema: NOTIFICATION_CHANNEL_SCHEMA,
|
||||||
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
|
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
|
||||||
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
|
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
|
||||||
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), info: {}}),
|
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), channel: {}}),
|
||||||
};
|
};
|
||||||
|
|
||||||
credentialsExtractor = {
|
credentialsExtractor = {
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||||
import type { NotificationChannelInfo } from '../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
||||||
import { TypedNotificationHandler } from '../../../../src/server/notifications/TypedNotificationHandler';
|
import { TypedNotificationHandler } from '../../../../src/server/notifications/TypedNotificationHandler';
|
||||||
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
|
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
|
||||||
|
|
||||||
describe('A TypedNotificationHandler', (): void => {
|
describe('A TypedNotificationHandler', (): void => {
|
||||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: topic.path,
|
topic: topic.path,
|
||||||
type: 'NotificationChannelType',
|
type: 'NotificationChannelType',
|
||||||
@ -23,27 +23,27 @@ describe('A TypedNotificationHandler', (): void => {
|
|||||||
handleSafe: jest.fn(),
|
handleSafe: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
handler = new TypedNotificationHandler(info.type, source);
|
handler = new TypedNotificationHandler(channel.type, source);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires the input info to have the correct type.', async(): Promise<void> => {
|
it('requires the input channel to have the correct type.', async(): Promise<void> => {
|
||||||
await expect(handler.canHandle({ info, topic })).resolves.toBeUndefined();
|
await expect(handler.canHandle({ channel, topic })).resolves.toBeUndefined();
|
||||||
|
|
||||||
const wrongInfo = {
|
const wrongChannel = {
|
||||||
...info,
|
...channel,
|
||||||
type: 'somethingElse',
|
type: 'somethingElse',
|
||||||
};
|
};
|
||||||
await expect(handler.canHandle({ info: wrongInfo, topic })).rejects.toThrow(NotImplementedHttpError);
|
await expect(handler.canHandle({ channel: wrongChannel, topic })).rejects.toThrow(NotImplementedHttpError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects input the source handler can not handle.', async(): Promise<void> => {
|
it('rejects input the source handler can not handle.', async(): Promise<void> => {
|
||||||
source.canHandle.mockRejectedValue(new Error('bad input'));
|
source.canHandle.mockRejectedValue(new Error('bad input'));
|
||||||
await expect(handler.canHandle({ info, topic })).rejects.toThrow('bad input');
|
await expect(handler.canHandle({ channel, topic })).rejects.toThrow('bad input');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls the source handle function.', async(): Promise<void> => {
|
it('calls the source handle function.', async(): Promise<void> => {
|
||||||
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
|
await expect(handler.handle({ channel, topic })).resolves.toBeUndefined();
|
||||||
expect(source.handle).toHaveBeenCalledTimes(1);
|
expect(source.handle).toHaveBeenCalledTimes(1);
|
||||||
expect(source.handle).toHaveBeenLastCalledWith({ info, topic });
|
expect(source.handle).toHaveBeenLastCalledWith({ channel, topic });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
import type { Logger } from '../../../../../src/logging/Logger';
|
import type { Logger } from '../../../../../src/logging/Logger';
|
||||||
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
||||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||||
import type { NotificationChannelInfo } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter';
|
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter';
|
||||||
import type {
|
import type {
|
||||||
WebHookFeatures,
|
WebHookFeatures,
|
||||||
@ -40,7 +40,7 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
published: '123',
|
published: '123',
|
||||||
};
|
};
|
||||||
let representation: Representation;
|
let representation: Representation;
|
||||||
const info: NotificationChannelInfo<WebHookFeatures> = {
|
const channel: NotificationChannel<WebHookFeatures> = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -79,7 +79,7 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(now);
|
jest.setSystemTime(now);
|
||||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
await expect(emitter.handle({ channel, representation })).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const call = fetchMock.mock.calls[0];
|
const call = fetchMock.mock.calls[0];
|
||||||
@ -95,13 +95,13 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
// Check all the DPoP token fields
|
// Check all the DPoP token fields
|
||||||
const decodedDpopToken = await jwtVerify(encodedDpopToken, publicObject, { issuer: trimTrailingSlashes(baseUrl) });
|
const decodedDpopToken = await jwtVerify(encodedDpopToken, publicObject, { issuer: trimTrailingSlashes(baseUrl) });
|
||||||
expect(decodedDpopToken.payload).toMatchObject({
|
expect(decodedDpopToken.payload).toMatchObject({
|
||||||
webid: info.features.webId,
|
webid: channel.features.webId,
|
||||||
azp: info.features.webId,
|
azp: channel.features.webId,
|
||||||
sub: info.features.webId,
|
sub: channel.features.webId,
|
||||||
cnf: { jkt: await calculateJwkThumbprint(publicJwk, 'sha256') },
|
cnf: { jkt: await calculateJwkThumbprint(publicJwk, 'sha256') },
|
||||||
iat: now,
|
iat: now,
|
||||||
exp: now + (20 * 60 * 1000),
|
exp: now + (20 * 60 * 1000),
|
||||||
aud: [ info.features.webId, 'solid' ],
|
aud: [ channel.features.webId, 'solid' ],
|
||||||
jti: expect.stringContaining('-'),
|
jti: expect.stringContaining('-'),
|
||||||
});
|
});
|
||||||
expect(decodedDpopToken.protectedHeader).toMatchObject({
|
expect(decodedDpopToken.protectedHeader).toMatchObject({
|
||||||
@ -111,7 +111,7 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
// CHeck the DPoP proof
|
// CHeck the DPoP proof
|
||||||
const decodedDpopProof = await jwtVerify(dpop, publicObject);
|
const decodedDpopProof = await jwtVerify(dpop, publicObject);
|
||||||
expect(decodedDpopProof.payload).toMatchObject({
|
expect(decodedDpopProof.payload).toMatchObject({
|
||||||
htu: info.features.target,
|
htu: channel.features.target,
|
||||||
htm: 'POST',
|
htm: 'POST',
|
||||||
iat: now,
|
iat: now,
|
||||||
jti: expect.stringContaining('-'),
|
jti: expect.stringContaining('-'),
|
||||||
@ -129,11 +129,11 @@ describe('A WebHookEmitter', (): void => {
|
|||||||
const logger = getLoggerFor('mock');
|
const logger = getLoggerFor('mock');
|
||||||
|
|
||||||
fetchMock.mockResolvedValue({ status: 400, text: async(): Promise<string> => 'invalid request' });
|
fetchMock.mockResolvedValue({ status: 400, text: async(): Promise<string> => 'invalid request' });
|
||||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
await expect(emitter.handle({ channel, representation })).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
expect(logger.error).toHaveBeenLastCalledWith(
|
expect(logger.error).toHaveBeenLastCalledWith(
|
||||||
`There was an issue emitting a WebHook notification with target ${info.features.target}: invalid request`,
|
`There was an issue emitting a WebHook notification with target ${channel.features.target}: invalid request`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,8 +6,8 @@ import {
|
|||||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||||
import type { Logger } from '../../../../../src/logging/Logger';
|
import type { Logger } from '../../../../../src/logging/Logger';
|
||||||
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
||||||
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type {
|
import type {
|
||||||
NotificationChannelInfo,
|
|
||||||
NotificationChannelStorage,
|
NotificationChannelStorage,
|
||||||
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
||||||
@ -31,14 +31,14 @@ jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
|||||||
describe('A WebHookSubscription2021', (): void => {
|
describe('A WebHookSubscription2021', (): void => {
|
||||||
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
|
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
|
||||||
const target = 'http://example.org/somewhere-else';
|
const target = 'http://example.org/somewhere-else';
|
||||||
let channel: InferType<WebHookSubscription2021['schema']>;
|
let json: InferType<WebHookSubscription2021['schema']>;
|
||||||
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
|
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
|
||||||
let storage: jest.Mocked<NotificationChannelStorage<WebHookFeatures>>;
|
let storage: jest.Mocked<NotificationChannelStorage<WebHookFeatures>>;
|
||||||
let stateHandler: jest.Mocked<StateHandler>;
|
let stateHandler: jest.Mocked<StateHandler>;
|
||||||
let channelType: WebHookSubscription2021;
|
let channelType: WebHookSubscription2021;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
channel = {
|
json = {
|
||||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||||
type: 'WebHookSubscription2021',
|
type: 'WebHookSubscription2021',
|
||||||
topic: 'https://storage.example/resource',
|
topic: 'https://storage.example/resource',
|
||||||
@ -51,7 +51,7 @@ describe('A WebHookSubscription2021', (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
storage = {
|
storage = {
|
||||||
create: jest.fn((features: WebHookFeatures): NotificationChannelInfo<WebHookFeatures> => ({
|
create: jest.fn((features: WebHookFeatures): NotificationChannel<WebHookFeatures> => ({
|
||||||
id: '123',
|
id: '123',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'WebHookSubscription2021',
|
type: 'WebHookSubscription2021',
|
||||||
@ -73,19 +73,19 @@ describe('A WebHookSubscription2021', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('correctly parses notification channel bodies.', async(): Promise<void> => {
|
it('correctly parses notification channel bodies.', async(): Promise<void> => {
|
||||||
await expect(channelType.schema.isValid(channel)).resolves.toBe(true);
|
await expect(channelType.schema.isValid(json)).resolves.toBe(true);
|
||||||
|
|
||||||
channel.type = 'something else';
|
json.type = 'something else';
|
||||||
await expect(channelType.schema.isValid(channel)).resolves.toBe(false);
|
await expect(channelType.schema.isValid(json)).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires Read permissions on the topic.', async(): Promise<void> => {
|
it('requires Read permissions on the topic.', async(): Promise<void> => {
|
||||||
await expect(channelType.extractModes(channel)).resolves
|
await expect(channelType.extractModes(json)).resolves
|
||||||
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
|
.toEqual(new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the info and returns a valid response when subscribing.', async(): Promise<void> => {
|
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
|
||||||
const { response } = await channelType.subscribe(channel, credentials);
|
const { response } = await channelType.subscribe(json, credentials);
|
||||||
expect(response.metadata.contentType).toBe('application/ld+json');
|
expect(response.metadata.contentType).toBe('application/ld+json');
|
||||||
await expect(readJsonStream(response.data)).resolves.toEqual({
|
await expect(readJsonStream(response.data)).resolves.toEqual({
|
||||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||||
@ -97,26 +97,26 @@ describe('A WebHookSubscription2021', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
|
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
|
||||||
await expect(channelType.subscribe(channel, {})).rejects
|
await expect(channelType.subscribe(json, {})).rejects
|
||||||
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
|
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls the state handler once the response has been read.', async(): Promise<void> => {
|
it('calls the state handler once the response has been read.', async(): Promise<void> => {
|
||||||
const { response, info } = await channelType.subscribe(channel, credentials);
|
const { response, channel } = await channelType.subscribe(json, credentials);
|
||||||
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
|
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
// Read out data to end stream correctly
|
// Read out data to end stream correctly
|
||||||
await readableToString(response.data);
|
await readableToString(response.data);
|
||||||
|
|
||||||
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
|
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ info });
|
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ channel });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an error if something went wrong emitting the state notification.', async(): Promise<void> => {
|
it('logs an error if something went wrong emitting the state notification.', async(): Promise<void> => {
|
||||||
const logger = getLoggerFor('mock');
|
const logger = getLoggerFor('mock');
|
||||||
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
|
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
|
||||||
|
|
||||||
const { response } = await channelType.subscribe(channel, credentials);
|
const { response } = await channelType.subscribe(json, credentials);
|
||||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
// Read out data to end stream correctly
|
// Read out data to end stream correctly
|
||||||
|
@ -43,7 +43,7 @@ describe('A WebHookUnsubscriber', (): void => {
|
|||||||
unsubscriber = new WebHookUnsubscriber(credentialsExtractor, storage);
|
unsubscriber = new WebHookUnsubscriber(credentialsExtractor, storage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects if the id does not match any stored info.', async(): Promise<void> => {
|
it('rejects if the id does not match any stored channel.', async(): Promise<void> => {
|
||||||
storage.get.mockResolvedValue(undefined);
|
storage.get.mockResolvedValue(undefined);
|
||||||
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(NotFoundHttpError);
|
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(NotFoundHttpError);
|
||||||
expect(storage.delete).toHaveBeenCalledTimes(0);
|
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||||
@ -55,7 +55,7 @@ describe('A WebHookUnsubscriber', (): void => {
|
|||||||
expect(storage.delete).toHaveBeenCalledTimes(0);
|
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the corresponding info.', async(): Promise<void> => {
|
it('deletes the corresponding channel.', async(): Promise<void> => {
|
||||||
await expect(unsubscriber.handle({ operation, request, response }))
|
await expect(unsubscriber.handle({ operation, request, response }))
|
||||||
.resolves.toEqual(new ResetResponseDescription());
|
.resolves.toEqual(new ResetResponseDescription());
|
||||||
expect(storage.delete).toHaveBeenCalledTimes(1);
|
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||||
import type {
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
NotificationChannelInfo,
|
|
||||||
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
|
||||||
import {
|
import {
|
||||||
WebSocket2021Emitter,
|
WebSocket2021Emitter,
|
||||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
|
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
|
||||||
@ -11,7 +9,7 @@ import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap';
|
|||||||
import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap';
|
import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap';
|
||||||
|
|
||||||
describe('A WebSocket2021Emitter', (): void => {
|
describe('A WebSocket2021Emitter', (): void => {
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -34,17 +32,17 @@ describe('A WebSocket2021Emitter', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('emits notifications to the stored WebSockets.', async(): Promise<void> => {
|
it('emits notifications to the stored WebSockets.', async(): Promise<void> => {
|
||||||
socketMap.add(info.id, webSocket);
|
socketMap.add(channel.id, webSocket);
|
||||||
|
|
||||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
await expect(emitter.handle({ channel, representation })).resolves.toBeUndefined();
|
||||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||||
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destroys the representation if there is no matching WebSocket.', async(): Promise<void> => {
|
it('destroys the representation if there is no matching WebSocket.', async(): Promise<void> => {
|
||||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
await expect(emitter.handle({ channel, representation })).resolves.toBeUndefined();
|
||||||
expect(webSocket.send).toHaveBeenCalledTimes(0);
|
expect(webSocket.send).toHaveBeenCalledTimes(0);
|
||||||
expect(representation.data.destroyed).toBe(true);
|
expect(representation.data.destroyed).toBe(true);
|
||||||
});
|
});
|
||||||
@ -53,11 +51,11 @@ describe('A WebSocket2021Emitter', (): void => {
|
|||||||
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||||
webSocket2.send = jest.fn();
|
webSocket2.send = jest.fn();
|
||||||
|
|
||||||
socketMap.add(info.id, webSocket);
|
socketMap.add(channel.id, webSocket);
|
||||||
socketMap.add(info.id, webSocket2);
|
socketMap.add(channel.id, webSocket2);
|
||||||
|
|
||||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
await expect(emitter.handle({ channel, representation })).resolves.toBeUndefined();
|
||||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||||
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
||||||
expect(webSocket2.send).toHaveBeenCalledTimes(1);
|
expect(webSocket2.send).toHaveBeenCalledTimes(1);
|
||||||
@ -67,16 +65,16 @@ describe('A WebSocket2021Emitter', (): void => {
|
|||||||
it('only sends to the matching WebSockets.', async(): Promise<void> => {
|
it('only sends to the matching WebSockets.', async(): Promise<void> => {
|
||||||
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||||
webSocket2.send = jest.fn();
|
webSocket2.send = jest.fn();
|
||||||
const info2: NotificationChannelInfo = {
|
const channel2: NotificationChannel = {
|
||||||
...info,
|
...channel,
|
||||||
id: 'other',
|
id: 'other',
|
||||||
};
|
};
|
||||||
|
|
||||||
socketMap.add(info.id, webSocket);
|
socketMap.add(channel.id, webSocket);
|
||||||
socketMap.add(info2.id, webSocket2);
|
socketMap.add(channel2.id, webSocket2);
|
||||||
|
|
||||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
await expect(emitter.handle({ channel, representation })).resolves.toBeUndefined();
|
||||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||||
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
||||||
expect(webSocket2.send).toHaveBeenCalledTimes(0);
|
expect(webSocket2.send).toHaveBeenCalledTimes(0);
|
||||||
|
@ -5,8 +5,8 @@ import {
|
|||||||
AbsolutePathInteractionRoute,
|
AbsolutePathInteractionRoute,
|
||||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||||
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||||
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type {
|
import type {
|
||||||
NotificationChannelInfo,
|
|
||||||
NotificationChannelStorage,
|
NotificationChannelStorage,
|
||||||
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
import type {
|
import type {
|
||||||
@ -27,7 +27,7 @@ jest.mock('ws', (): any => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('A WebSocket2021Listener', (): void => {
|
describe('A WebSocket2021Listener', (): void => {
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -52,7 +52,7 @@ describe('A WebSocket2021Listener', (): void => {
|
|||||||
upgradeRequest = { url: `/foo?auth=${auth}` } as any;
|
upgradeRequest = { url: `/foo?auth=${auth}` } as any;
|
||||||
|
|
||||||
storage = {
|
storage = {
|
||||||
get: jest.fn().mockResolvedValue(info),
|
get: jest.fn().mockResolvedValue(channel),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
handler = {
|
handler = {
|
||||||
@ -119,6 +119,6 @@ describe('A WebSocket2021Listener', (): void => {
|
|||||||
expect(webSocket.send).toHaveBeenCalledTimes(0);
|
expect(webSocket.send).toHaveBeenCalledTimes(0);
|
||||||
expect(webSocket.close).toHaveBeenCalledTimes(0);
|
expect(webSocket.close).toHaveBeenCalledTimes(0);
|
||||||
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
|
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(handler.handleSafe).toHaveBeenLastCalledWith({ webSocket, info });
|
expect(handler.handleSafe).toHaveBeenLastCalledWith({ webSocket, channel });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type {
|
import type {
|
||||||
NotificationChannelInfo,
|
|
||||||
NotificationChannelStorage,
|
NotificationChannelStorage,
|
||||||
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiM
|
|||||||
import { flushPromises } from '../../../../util/Util';
|
import { flushPromises } from '../../../../util/Util';
|
||||||
|
|
||||||
describe('A WebSocket2021Storer', (): void => {
|
describe('A WebSocket2021Storer', (): void => {
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -39,23 +39,23 @@ describe('A WebSocket2021Storer', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stores WebSockets.', async(): Promise<void> => {
|
it('stores WebSockets.', async(): Promise<void> => {
|
||||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
await expect(storer.handle({ channel, webSocket })).resolves.toBeUndefined();
|
||||||
expect([ ...socketMap.keys() ]).toHaveLength(1);
|
expect([ ...socketMap.keys() ]).toHaveLength(1);
|
||||||
expect(socketMap.has(info.id)).toBe(true);
|
expect(socketMap.has(channel.id)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes closed WebSockets.', async(): Promise<void> => {
|
it('removes closed WebSockets.', async(): Promise<void> => {
|
||||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
await expect(storer.handle({ channel, webSocket })).resolves.toBeUndefined();
|
||||||
expect(socketMap.has(info.id)).toBe(true);
|
expect(socketMap.has(channel.id)).toBe(true);
|
||||||
webSocket.emit('close');
|
webSocket.emit('close');
|
||||||
expect(socketMap.has(info.id)).toBe(false);
|
expect(socketMap.has(channel.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes erroring WebSockets.', async(): Promise<void> => {
|
it('removes erroring WebSockets.', async(): Promise<void> => {
|
||||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
await expect(storer.handle({ channel, webSocket })).resolves.toBeUndefined();
|
||||||
expect(socketMap.has(info.id)).toBe(true);
|
expect(socketMap.has(channel.id)).toBe(true);
|
||||||
webSocket.emit('error');
|
webSocket.emit('error');
|
||||||
expect(socketMap.has(info.id)).toBe(false);
|
expect(socketMap.has(channel.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes expired WebSockets.', async(): Promise<void> => {
|
it('removes expired WebSockets.', async(): Promise<void> => {
|
||||||
@ -68,18 +68,18 @@ describe('A WebSocket2021Storer', (): void => {
|
|||||||
webSocket2.close = jest.fn();
|
webSocket2.close = jest.fn();
|
||||||
const webSocketOther: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
const webSocketOther: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||||
webSocketOther.close = jest.fn();
|
webSocketOther.close = jest.fn();
|
||||||
const infoOther: NotificationChannelInfo = {
|
const channelOther: NotificationChannel = {
|
||||||
...info,
|
...channel,
|
||||||
id: 'other',
|
id: 'other',
|
||||||
};
|
};
|
||||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
await expect(storer.handle({ channel, webSocket })).resolves.toBeUndefined();
|
||||||
await expect(storer.handle({ info, webSocket: webSocket2 })).resolves.toBeUndefined();
|
await expect(storer.handle({ channel, webSocket: webSocket2 })).resolves.toBeUndefined();
|
||||||
await expect(storer.handle({ info: infoOther, webSocket: webSocketOther })).resolves.toBeUndefined();
|
await expect(storer.handle({ channel: channelOther, webSocket: webSocketOther })).resolves.toBeUndefined();
|
||||||
|
|
||||||
// `info` expired, `infoOther` did not
|
// `channel` expired, `channelOther` did not
|
||||||
storage.get.mockImplementation((id): any => {
|
storage.get.mockImplementation((id): any => {
|
||||||
if (id === infoOther.id) {
|
if (id === channelOther.id) {
|
||||||
return infoOther;
|
return channelOther;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import { AccessMode } from '../../../../../src/authorization/permissions/Permiss
|
|||||||
import {
|
import {
|
||||||
AbsolutePathInteractionRoute,
|
AbsolutePathInteractionRoute,
|
||||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||||
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
import type { NotificationChannelJson } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
import {
|
import {
|
||||||
WebSocketSubscription2021,
|
WebSocketSubscription2021,
|
||||||
@ -11,7 +11,7 @@ import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap
|
|||||||
import { readJsonStream } from '../../../../../src/util/StreamUtil';
|
import { readJsonStream } from '../../../../../src/util/StreamUtil';
|
||||||
|
|
||||||
describe('A WebSocketSubscription2021', (): void => {
|
describe('A WebSocketSubscription2021', (): void => {
|
||||||
let channel: NotificationChannel;
|
let channel: NotificationChannelJson;
|
||||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||||
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
|
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
|
||||||
let channelType: WebSocketSubscription2021;
|
let channelType: WebSocketSubscription2021;
|
||||||
@ -58,7 +58,7 @@ describe('A WebSocketSubscription2021', (): void => {
|
|||||||
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
|
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the info and returns a valid response when subscribing.', async(): Promise<void> => {
|
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
|
||||||
const { response } = await channelType.subscribe(channel);
|
const { response } = await channelType.subscribe(channel);
|
||||||
expect(response.metadata.contentType).toBe('application/ld+json');
|
expect(response.metadata.contentType).toBe('application/ld+json');
|
||||||
await expect(readJsonStream(response.data)).resolves.toEqual({
|
await expect(readJsonStream(response.data)).resolves.toEqual({
|
||||||
|
@ -4,13 +4,13 @@ import type { ResourceIdentifier } from '../../../../../src/http/representation/
|
|||||||
import {
|
import {
|
||||||
ActivityNotificationGenerator,
|
ActivityNotificationGenerator,
|
||||||
} from '../../../../../src/server/notifications/generate/ActivityNotificationGenerator';
|
} from '../../../../../src/server/notifications/generate/ActivityNotificationGenerator';
|
||||||
import type { NotificationChannelInfo } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
|
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
|
||||||
import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
|
import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
|
||||||
|
|
||||||
describe('An ActivityNotificationGenerator', (): void => {
|
describe('An ActivityNotificationGenerator', (): void => {
|
||||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: topic.path,
|
topic: topic.path,
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -35,8 +35,8 @@ describe('An ActivityNotificationGenerator', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('only handles defined activities.', async(): Promise<void> => {
|
it('only handles defined activities.', async(): Promise<void> => {
|
||||||
await expect(generator.canHandle({ topic, info })).rejects.toThrow('Only defined activities are supported.');
|
await expect(generator.canHandle({ topic, channel })).rejects.toThrow('Only defined activities are supported.');
|
||||||
await expect(generator.canHandle({ topic, info, activity })).resolves.toBeUndefined();
|
await expect(generator.canHandle({ topic, channel, activity })).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a notification.', async(): Promise<void> => {
|
it('generates a notification.', async(): Promise<void> => {
|
||||||
@ -45,7 +45,7 @@ describe('An ActivityNotificationGenerator', (): void => {
|
|||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(ms);
|
jest.setSystemTime(ms);
|
||||||
|
|
||||||
await expect(generator.handle({ topic, info, activity })).resolves.toEqual({
|
await expect(generator.handle({ topic, channel, activity })).resolves.toEqual({
|
||||||
'@context': [
|
'@context': [
|
||||||
'https://www.w3.org/ns/activitystreams',
|
'https://www.w3.org/ns/activitystreams',
|
||||||
'https://www.w3.org/ns/solid/notification/v1',
|
'https://www.w3.org/ns/solid/notification/v1',
|
||||||
|
@ -2,12 +2,12 @@ import type { ResourceIdentifier } from '../../../../../src/http/representation/
|
|||||||
import {
|
import {
|
||||||
DeleteNotificationGenerator,
|
DeleteNotificationGenerator,
|
||||||
} from '../../../../../src/server/notifications/generate/DeleteNotificationGenerator';
|
} from '../../../../../src/server/notifications/generate/DeleteNotificationGenerator';
|
||||||
import type { NotificationChannelInfo } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import { AS } from '../../../../../src/util/Vocabularies';
|
import { AS } from '../../../../../src/util/Vocabularies';
|
||||||
|
|
||||||
describe('A DeleteNotificationGenerator', (): void => {
|
describe('A DeleteNotificationGenerator', (): void => {
|
||||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: topic.path,
|
topic: topic.path,
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -18,10 +18,11 @@ describe('A DeleteNotificationGenerator', (): void => {
|
|||||||
const generator = new DeleteNotificationGenerator();
|
const generator = new DeleteNotificationGenerator();
|
||||||
|
|
||||||
it('can only handle input with the Delete activity.', async(): Promise<void> => {
|
it('can only handle input with the Delete activity.', async(): Promise<void> => {
|
||||||
await expect(generator.canHandle({ topic, info })).rejects.toThrow('Only Delete activity updates are supported.');
|
await expect(generator.canHandle({ topic, channel })).rejects
|
||||||
await expect(generator.canHandle({ topic, info, activity: AS.terms.Update }))
|
.toThrow('Only Delete activity updates are supported.');
|
||||||
|
await expect(generator.canHandle({ topic, channel, activity: AS.terms.Update }))
|
||||||
.rejects.toThrow('Only Delete activity updates are supported.');
|
.rejects.toThrow('Only Delete activity updates are supported.');
|
||||||
await expect(generator.canHandle({ topic, info, activity })).resolves.toBeUndefined();
|
await expect(generator.canHandle({ topic, channel, activity })).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a Delete notification.', async(): Promise<void> => {
|
it('generates a Delete notification.', async(): Promise<void> => {
|
||||||
@ -30,7 +31,7 @@ describe('A DeleteNotificationGenerator', (): void => {
|
|||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(ms);
|
jest.setSystemTime(ms);
|
||||||
|
|
||||||
await expect(generator.handle({ topic, info, activity })).resolves.toEqual({
|
await expect(generator.handle({ topic, channel, activity })).resolves.toEqual({
|
||||||
'@context': [
|
'@context': [
|
||||||
'https://www.w3.org/ns/activitystreams',
|
'https://www.w3.org/ns/activitystreams',
|
||||||
'https://www.w3.org/ns/solid/notification/v1',
|
'https://www.w3.org/ns/solid/notification/v1',
|
||||||
|
@ -4,13 +4,13 @@ import {
|
|||||||
StateNotificationGenerator,
|
StateNotificationGenerator,
|
||||||
} from '../../../../../src/server/notifications/generate/StateNotificationGenerator';
|
} from '../../../../../src/server/notifications/generate/StateNotificationGenerator';
|
||||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||||
import type { NotificationChannelInfo } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import type { ResourceSet } from '../../../../../src/storage/ResourceSet';
|
import type { ResourceSet } from '../../../../../src/storage/ResourceSet';
|
||||||
import { AS } from '../../../../../src/util/Vocabularies';
|
import { AS } from '../../../../../src/util/Vocabularies';
|
||||||
|
|
||||||
describe('A StateNotificationGenerator', (): void => {
|
describe('A StateNotificationGenerator', (): void => {
|
||||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: topic.path,
|
topic: topic.path,
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -44,25 +44,25 @@ describe('A StateNotificationGenerator', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns the source notification if there is an activity.', async(): Promise<void> => {
|
it('returns the source notification if there is an activity.', async(): Promise<void> => {
|
||||||
await expect(generator.handle({ topic, info, activity: AS.terms.Update })).resolves.toBe(notification);
|
await expect(generator.handle({ topic, channel, activity: AS.terms.Update })).resolves.toBe(notification);
|
||||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Update });
|
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, channel, activity: AS.terms.Update });
|
||||||
expect(resourceSet.hasResource).toHaveBeenCalledTimes(0);
|
expect(resourceSet.hasResource).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls the source with an Update notification if the topic exists.', async(): Promise<void> => {
|
it('calls the source with an Update notification if the topic exists.', async(): Promise<void> => {
|
||||||
resourceSet.hasResource.mockResolvedValue(true);
|
resourceSet.hasResource.mockResolvedValue(true);
|
||||||
await expect(generator.handle({ topic, info })).resolves.toBe(notification);
|
await expect(generator.handle({ topic, channel })).resolves.toBe(notification);
|
||||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Update });
|
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, channel, activity: AS.terms.Update });
|
||||||
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
|
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls the source with a Delete notification if the topic does not exist.', async(): Promise<void> => {
|
it('calls the source with a Delete notification if the topic does not exist.', async(): Promise<void> => {
|
||||||
resourceSet.hasResource.mockResolvedValue(false);
|
resourceSet.hasResource.mockResolvedValue(false);
|
||||||
await expect(generator.handle({ topic, info })).resolves.toBe(notification);
|
await expect(generator.handle({ topic, channel })).resolves.toBe(notification);
|
||||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Delete });
|
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, channel, activity: AS.terms.Delete });
|
||||||
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
|
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||||
import type { Representation } from '../../../../../src/http/representation/Representation';
|
import type { Representation } from '../../../../../src/http/representation/Representation';
|
||||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||||
import type { NotificationChannelInfo } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import {
|
import {
|
||||||
ConvertingNotificationSerializer,
|
ConvertingNotificationSerializer,
|
||||||
} from '../../../../../src/server/notifications/serialize/ConvertingNotificationSerializer';
|
} from '../../../../../src/server/notifications/serialize/ConvertingNotificationSerializer';
|
||||||
@ -9,7 +9,7 @@ import type { NotificationSerializer } from '../../../../../src/server/notificat
|
|||||||
import type { RepresentationConverter } from '../../../../../src/storage/conversion/RepresentationConverter';
|
import type { RepresentationConverter } from '../../../../../src/storage/conversion/RepresentationConverter';
|
||||||
|
|
||||||
describe('A ConvertingNotificationSerializer', (): void => {
|
describe('A ConvertingNotificationSerializer', (): void => {
|
||||||
let info: NotificationChannelInfo;
|
let channel: NotificationChannel;
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
'@context': [
|
'@context': [
|
||||||
'https://www.w3.org/ns/activitystreams',
|
'https://www.w3.org/ns/activitystreams',
|
||||||
@ -26,7 +26,7 @@ describe('A ConvertingNotificationSerializer', (): void => {
|
|||||||
let serializer: ConvertingNotificationSerializer;
|
let serializer: ConvertingNotificationSerializer;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
info = {
|
channel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -49,19 +49,19 @@ describe('A ConvertingNotificationSerializer', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can handle input its source can handle.', async(): Promise<void> => {
|
it('can handle input its source can handle.', async(): Promise<void> => {
|
||||||
await expect(serializer.canHandle({ info, notification })).resolves.toBeUndefined();
|
await expect(serializer.canHandle({ channel, notification })).resolves.toBeUndefined();
|
||||||
source.canHandle.mockRejectedValue(new Error('bad input'));
|
source.canHandle.mockRejectedValue(new Error('bad input'));
|
||||||
await expect(serializer.canHandle({ info, notification })).rejects.toThrow('bad input');
|
await expect(serializer.canHandle({ channel, notification })).rejects.toThrow('bad input');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the source result if there is no accept value.', async(): Promise<void> => {
|
it('returns the source result if there is no accept value.', async(): Promise<void> => {
|
||||||
await expect(serializer.handle({ info, notification })).resolves.toBe(representation);
|
await expect(serializer.handle({ channel, notification })).resolves.toBe(representation);
|
||||||
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
|
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts the source result if there is an accept value.', async(): Promise<void> => {
|
it('converts the source result if there is an accept value.', async(): Promise<void> => {
|
||||||
info.accept = 'text/turtle';
|
channel.accept = 'text/turtle';
|
||||||
await expect(serializer.handle({ info, notification })).resolves.toBe(representation);
|
await expect(serializer.handle({ channel, notification })).resolves.toBe(representation);
|
||||||
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
|
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(converter.handleSafe).toHaveBeenLastCalledWith({
|
expect(converter.handleSafe).toHaveBeenLastCalledWith({
|
||||||
representation,
|
representation,
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||||
import type { NotificationChannelInfo } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||||
import {
|
import {
|
||||||
JsonLdNotificationSerializer,
|
JsonLdNotificationSerializer,
|
||||||
} from '../../../../../src/server/notifications/serialize/JsonLdNotificationSerializer';
|
} from '../../../../../src/server/notifications/serialize/JsonLdNotificationSerializer';
|
||||||
import { readableToString } from '../../../../../src/util/StreamUtil';
|
import { readableToString } from '../../../../../src/util/StreamUtil';
|
||||||
|
|
||||||
describe('A JsonLdNotificationSerializer', (): void => {
|
describe('A JsonLdNotificationSerializer', (): void => {
|
||||||
const info: NotificationChannelInfo = {
|
const channel: NotificationChannel = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
topic: 'http://example.com/foo',
|
topic: 'http://example.com/foo',
|
||||||
type: 'type',
|
type: 'type',
|
||||||
@ -27,7 +27,7 @@ describe('A JsonLdNotificationSerializer', (): void => {
|
|||||||
const serializer = new JsonLdNotificationSerializer();
|
const serializer = new JsonLdNotificationSerializer();
|
||||||
|
|
||||||
it('converts notifications into JSON-LD.', async(): Promise<void> => {
|
it('converts notifications into JSON-LD.', async(): Promise<void> => {
|
||||||
const representation = await serializer.handle({ notification, info });
|
const representation = await serializer.handle({ notification, channel });
|
||||||
expect(representation.metadata.contentType).toBe('application/ld+json');
|
expect(representation.metadata.contentType).toBe('application/ld+json');
|
||||||
expect(JSON.parse(await readableToString(representation.data))).toEqual(notification);
|
expect(JSON.parse(await readableToString(representation.data))).toEqual(notification);
|
||||||
});
|
});
|
||||||
|
@ -4,7 +4,7 @@ import { fetch } from 'cross-fetch';
|
|||||||
* Subscribes to a notification channel.
|
* Subscribes to a notification channel.
|
||||||
* @param type - The type of the notification channel. E.g. "WebSocketSubscription2021".
|
* @param type - The type of the notification channel. E.g. "WebSocketSubscription2021".
|
||||||
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
|
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
|
||||||
* @param subscriptionUrl - The subscription resource URL where the request needs to be sent to.
|
* @param subscriptionUrl - The subscription URL where the request needs to be sent to.
|
||||||
* @param topic - The topic to subscribe to.
|
* @param topic - The topic to subscribe to.
|
||||||
* @param features - Any extra fields that need to be added to the subscription body.
|
* @param features - Any extra fields that need to be added to the subscription body.
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user