From c6ad10ca790bd500442cf2b6569e1bf093955994 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Thu, 2 Jan 2025 13:58:16 -0500 Subject: [PATCH] Tests for error handling --- package-lock.json | 8 ++ packages/solid/package.json | 1 + packages/solid/src/resource/Resource.ts | 8 +- .../notifications/NotificationSubscription.ts | 3 + .../Websocket2023NotificationSubscription.ts | 85 ++++++++++++++----- .../results/NotificationErrors.ts | 2 +- packages/solid/test/Integration.test.ts | 38 ++++++++- ...socket2023NotificationSubscription.test.ts | 57 +++++++++++++ .../server-config-without-websocket.json | 44 ++++++++++ packages/solid/test/solidServer.helper.ts | 4 +- 10 files changed, 220 insertions(+), 30 deletions(-) create mode 100644 packages/solid/test/Websocket2023NotificationSubscription.test.ts create mode 100644 packages/solid/test/configs/server-config-without-websocket.json diff --git a/package-lock.json b/package-lock.json index 7f8b7ff..549b9d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7569,6 +7569,13 @@ "n3": "^1.17.2" } }, + "node_modules/@solid-notifications/types": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solid-notifications/types/-/types-0.1.2.tgz", + "integrity": "sha512-0SP6XmOjFhqt/m4FFXnYh6slSiXMoheO3UpU7POSDStLSb6tLVAQLiy0hBKvNyGBLlftRObHWoBWlt2X/LhVRg==", + "dev": true, + "license": "MIT" + }, "node_modules/@solid/access-control-policy": { "version": "0.1.3", "dev": true, @@ -29628,6 +29635,7 @@ "@ldo/cli": "^0.0.1-alpha.28", "@rdfjs/data-model": "^1.2.0", "@rdfjs/types": "^1.0.1", + "@solid-notifications/types": "^0.1.2", "@solid/community-server": "^7.1.3", "@types/jest": "^27.0.3", "cross-env": "^7.0.3", diff --git a/packages/solid/package.json b/packages/solid/package.json index 2600356..1717157 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -29,6 +29,7 @@ "@ldo/cli": "^0.0.1-alpha.28", "@rdfjs/data-model": "^1.2.0", "@rdfjs/types": "^1.0.1", + "@solid-notifications/types": "^0.1.2", "@solid/community-server": "^7.1.3", "@types/jest": "^27.0.3", "cross-env": "^7.0.3", diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 881c065..881b9a3 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -334,8 +334,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ``` */ isSubscribedToNotifications(): boolean { - // TODO - throw new Error("Not Implemented"); + return !!this.notificationSubscription; } /** @@ -732,10 +731,13 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ /** * TODO */ - async subscribeToNotifications(): Promise { + async subscribeToNotifications( + onNotificationError?: (err: Error) => void, + ): Promise { this.notificationSubscription = new Websocket2023NotificationSubscription( this, this.onNotification.bind(this), + onNotificationError, this.context, ); return await this.notificationSubscription.open(); diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index d1c060d..1d49e58 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -18,15 +18,18 @@ export type CloseSubscriptionResult = export abstract class NotificationSubscription { protected resource: Resource; protected onNotification: (message: NotificationMessage) => void; + protected onError?: (err: Error) => void; protected context: SolidLdoDatasetContext; constructor( resource: Resource, onNotification: (message: NotificationMessage) => void, + onError: ((err: Error) => void) | undefined, context: SolidLdoDatasetContext, ) { this.resource = resource; this.onNotification = onNotification; + this.onError = onError; this.context = context; } diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts index e62bec7..5434b7b 100644 --- a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -8,37 +8,35 @@ import { SubscriptionClient } from "@solid-notifications/subscription"; import { WebSocket } from "ws"; import { UnsupportedNotificationError } from "./results/NotificationErrors"; import type { NotificationMessage } from "./NotificationMessage"; +import type { Resource } from "../Resource"; +import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; +import type { + ChannelType, + NotificationChannel, +} from "@solid-notifications/types"; const CHANNEL_TYPE = "http://www.w3.org/ns/solid/notifications#WebSocketChannel2023"; export class Websocket2023NotificationSubscription extends NotificationSubscription { private socket: WebSocket | undefined; + private createWebsocket: (address: string) => WebSocket; + + constructor( + resource: Resource, + onNotification: (message: NotificationMessage) => void, + onError: ((err: Error) => void) | undefined, + context: SolidLdoDatasetContext, + createWebsocket?: (address: string) => WebSocket, + ) { + super(resource, onNotification, onError, context); + this.createWebsocket = createWebsocket ?? createWebsocketDefault; + } async open(): Promise { - const client = new SubscriptionClient(this.context.fetch); try { - const notificationChannel = await client.subscribe( - this.resource.uri, - CHANNEL_TYPE, - ); - return new Promise((resolve) => { - this.socket = new WebSocket(notificationChannel.receiveFrom); - this.socket.onmessage = (message) => { - const messageData = message.data.toString(); - // TODO uncompliant Pod error on misformatted message - this.onNotification(JSON.parse(messageData) as NotificationMessage); - }; - this.socket.onerror = (err) => - resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); - this.socket.onopen = () => { - resolve({ - isError: false, - type: "subscribeToNotificationSuccess", - uri: this.resource.uri, - }); - }; - }); + const notificationChannel = await this.discoverNotificationChannel(); + return this.subscribeToWebsocket(notificationChannel); } catch (err) { if ( err instanceof Error && @@ -50,6 +48,45 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript } } + async discoverNotificationChannel(): Promise { + const client = new SubscriptionClient(this.context.fetch); + return await client.subscribe( + this.resource.uri, + CHANNEL_TYPE as ChannelType, + ); + } + + async subscribeToWebsocket( + notificationChannel: NotificationChannel, + ): Promise { + return new Promise((resolve) => { + let didResolve = false; + this.socket = this.createWebsocket( + notificationChannel.receiveFrom as string, + ); + this.socket.onmessage = (message) => { + const messageData = message.data.toString(); + // TODO uncompliant Pod error on misformatted message + this.onNotification(JSON.parse(messageData) as NotificationMessage); + }; + this.socket.onerror = (err) => { + if (!didResolve) { + resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); + } else { + this.onError?.(err.error); + } + }; + this.socket.onopen = () => { + didResolve = true; + resolve({ + isError: false, + type: "subscribeToNotificationSuccess", + uri: this.resource.uri, + }); + }; + }); + } + async close(): Promise { this.socket?.terminate(); return { @@ -59,3 +96,7 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript }; } } + +function createWebsocketDefault(address: string) { + return new WebSocket(address); +} diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts index 7bd7b10..2687b19 100644 --- a/packages/solid/src/resource/notifications/results/NotificationErrors.ts +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -1,5 +1,5 @@ import { ResourceError } from "../../../requester/results/error/ErrorResult"; export class UnsupportedNotificationError extends ResourceError { - readonly type = "unexpectedResourceError" as const; + readonly type = "unsupportedNotificationError" as const; } diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index 1b03fac..22b60e7 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -2038,6 +2038,8 @@ describe("Integration", () => { const subscriptionResult = await resource.subscribeToNotifications(); expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + expect(resource.isSubscribedToNotifications()).toBe(true); + await authFetch(SAMPLE_DATA_URI, { method: "PATCH", body: 'INSERT DATA { "Peter Parker" . }', @@ -2062,6 +2064,7 @@ describe("Integration", () => { expect(unsubscribeResponse.type).toBe( "unsubscribeFromNotificationSuccess", ); + expect(resource.isSubscribedToNotifications()).toBe(false); await authFetch(SAMPLE_DATA_URI, { method: "PATCH", body: 'INSERT DATA { "Miles Morales" . }', @@ -2079,8 +2082,6 @@ describe("Integration", () => { literal("Miles Morales"), ).size, ).toBe(0); - - await resource.unsubscribeFromNotifications(); }); it("handles notification when subscribed to a child that is deleted", async () => { @@ -2191,5 +2192,38 @@ describe("Integration", () => { await testContainer.unsubscribeFromNotifications(); }); + + it("returns an error when it cannot subscribe to a notification", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + + await app.stop(); + + const subscriptionResult = await resource.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("unexpectedResourceError"); + + await app.start(); + }); + + it("returns an error when the server doesnt support websockets", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + + await app.stop(); + const disabledWebsocketsApp = await createApp( + path.join(__dirname, "./configs/server-config-without-websocket.json"), + ); + await disabledWebsocketsApp.start(); + + const subscriptionResult = await resource.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("unsupportedNotificationError"); + + await disabledWebsocketsApp.stop(); + await app.start(); + }); + + it("causes no problems when unsubscribing when not subscribed", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.unsubscribeFromNotifications(); + expect(result.type).toBe("unsubscribeFromNotificationSuccess"); + }); }); }); diff --git a/packages/solid/test/Websocket2023NotificationSubscription.test.ts b/packages/solid/test/Websocket2023NotificationSubscription.test.ts new file mode 100644 index 0000000..ddf92bb --- /dev/null +++ b/packages/solid/test/Websocket2023NotificationSubscription.test.ts @@ -0,0 +1,57 @@ +import type { WebSocket, Event, ErrorEvent } from "ws"; +import { Websocket2023NotificationSubscription } from "../src/resource/notifications/Websocket2023NotificationSubscription"; +import type { SolidLdoDatasetContext } from "../src"; +import { Leaf } from "../src"; +import type { NotificationChannel } from "@solid-notifications/types"; + +describe("Websocket2023NotificationSubscription", () => { + it("returns an error when websockets have an error", async () => { + const WebSocketMock: WebSocket = {} as WebSocket; + const onErrorMock = jest.fn(); + + const subscription = new Websocket2023NotificationSubscription( + new Leaf("https://example.com", { + fetch, + } as unknown as SolidLdoDatasetContext), + () => {}, + onErrorMock, + {} as unknown as SolidLdoDatasetContext, + () => WebSocketMock, + ); + + const subPromise = subscription.subscribeToWebsocket({ + receiveFrom: "http://example.com", + } as unknown as NotificationChannel); + WebSocketMock.onopen?.({} as Event); + + const subscriptionResult = await subPromise; + expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); + + expect(onErrorMock).toHaveBeenCalled(); + }); + + it("returns an error when websockets have an error at the beginning", async () => { + const WebSocketMock: WebSocket = {} as WebSocket; + const onErrorMock = jest.fn(); + + const subscription = new Websocket2023NotificationSubscription( + new Leaf("https://example.com", { + fetch, + } as unknown as SolidLdoDatasetContext), + () => {}, + onErrorMock, + {} as unknown as SolidLdoDatasetContext, + () => WebSocketMock, + ); + + const subPromise = subscription.subscribeToWebsocket({ + receiveFrom: "http://example.com", + } as unknown as NotificationChannel); + WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); + const subscriptionResult = await subPromise; + + expect(subscriptionResult.type).toBe("unexpectedResourceError"); + }); +}); diff --git a/packages/solid/test/configs/server-config-without-websocket.json b/packages/solid/test/configs/server-config-without-websocket.json new file mode 100644 index 0000000..626d082 --- /dev/null +++ b/packages/solid/test/configs/server-config-without-websocket.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/initialize-root.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/webhooks.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/no-accounts.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/file.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/file.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": [ + "A Solid server that stores its resources on disk and uses WAC for authorization.", + "No registration and the root container is initialized to allow full access for everyone so make sure to change this." + ] + } + ] +} \ No newline at end of file diff --git a/packages/solid/test/solidServer.helper.ts b/packages/solid/test/solidServer.helper.ts index c70bb9b..b27b58b 100644 --- a/packages/solid/test/solidServer.helper.ts +++ b/packages/solid/test/solidServer.helper.ts @@ -14,7 +14,7 @@ export const WEB_ID = // Use an increased timeout, since the CSS server takes too much setup time. jest.setTimeout(40_000); -export async function createApp(): Promise { +export async function createApp(customConfigPath?: string): Promise { if (process.env.SERVER) { return { start: () => {}, @@ -28,7 +28,7 @@ export async function createApp(): Promise { mainModulePath: resolveModulePath(""), typeChecking: false, }, - config: resolveModulePath("config/file-root.json"), + config: customConfigPath ?? resolveModulePath("config/file-root.json"), variableBindings: {}, shorthand: { port: 3_001,