From 9ae7dea357daa20cfcfc16faa95dc0c99da7964d Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Thu, 20 Mar 2025 17:23:17 -0400 Subject: [PATCH] Added old tests and fixed the ErrorResult test --- .../connected-solid/test/.ldo/post.context.ts | 31 + .../connected-solid/test/.ldo/post.schema.ts | 155 ++ .../test/.ldo/post.shapeTypes.ts | 19 + .../connected-solid/test/.ldo/post.typings.ts | 45 + .../connected-solid/test/Integration.test.ts | 2264 +++++++++++++++++ .../test/LeafRequester.test.ts | 256 ++ .../test/RequestBatcher.test.ts | 112 + ...socket2023NotificationSubscription.test.ts | 48 + .../connected-solid/test/authFetch.helper.ts | 112 + .../server-config-without-websocket.json | 44 + .../test/configs/server-config.json | 43 + .../test/configs/solid-css-seed.json | 9 + .../test/guaranteeFetch.test.ts | 8 + packages/connected-solid/test/setup-tests.ts | 3 + .../test/solidServer.helper.ts | 40 + .../connected-solid/test/uriTypes.test.ts | 7 + packages/connected-solid/test/utils.helper.ts | 3 + packages/connected/jest.config.js | 1 - packages/connected/src/test.ts | 0 packages/connected/test/ErrorResult.test.ts | 66 + packages/connected/test/MockResource.ts | 65 + 21 files changed, 3330 insertions(+), 1 deletion(-) create mode 100644 packages/connected-solid/test/.ldo/post.context.ts create mode 100644 packages/connected-solid/test/.ldo/post.schema.ts create mode 100644 packages/connected-solid/test/.ldo/post.shapeTypes.ts create mode 100644 packages/connected-solid/test/.ldo/post.typings.ts create mode 100644 packages/connected-solid/test/Integration.test.ts create mode 100644 packages/connected-solid/test/LeafRequester.test.ts create mode 100644 packages/connected-solid/test/RequestBatcher.test.ts create mode 100644 packages/connected-solid/test/Websocket2023NotificationSubscription.test.ts create mode 100644 packages/connected-solid/test/authFetch.helper.ts create mode 100644 packages/connected-solid/test/configs/server-config-without-websocket.json create mode 100644 packages/connected-solid/test/configs/server-config.json create mode 100644 packages/connected-solid/test/configs/solid-css-seed.json create mode 100644 packages/connected-solid/test/guaranteeFetch.test.ts create mode 100644 packages/connected-solid/test/setup-tests.ts create mode 100644 packages/connected-solid/test/solidServer.helper.ts create mode 100644 packages/connected-solid/test/uriTypes.test.ts create mode 100644 packages/connected-solid/test/utils.helper.ts delete mode 100644 packages/connected/src/test.ts create mode 100644 packages/connected/test/ErrorResult.test.ts create mode 100644 packages/connected/test/MockResource.ts diff --git a/packages/connected-solid/test/.ldo/post.context.ts b/packages/connected-solid/test/.ldo/post.context.ts new file mode 100644 index 0000000..dafbe33 --- /dev/null +++ b/packages/connected-solid/test/.ldo/post.context.ts @@ -0,0 +1,31 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * postContext: JSONLD Context for post + * ============================================================================= + */ +export const postContext: ContextDefinition = { + type: { + "@id": "@type", + }, + SocialMediaPosting: "http://schema.org/SocialMediaPosting", + CreativeWork: "http://schema.org/CreativeWork", + Thing: "http://schema.org/Thing", + articleBody: { + "@id": "http://schema.org/articleBody", + "@type": "http://www.w3.org/2001/XMLSchema#string", + }, + uploadDate: { + "@id": "http://schema.org/uploadDate", + "@type": "http://www.w3.org/2001/XMLSchema#date", + }, + image: { + "@id": "http://schema.org/image", + "@type": "@id", + }, + publisher: { + "@id": "http://schema.org/publisher", + "@type": "@id", + }, +}; diff --git a/packages/connected-solid/test/.ldo/post.schema.ts b/packages/connected-solid/test/.ldo/post.schema.ts new file mode 100644 index 0000000..39e8b63 --- /dev/null +++ b/packages/connected-solid/test/.ldo/post.schema.ts @@ -0,0 +1,155 @@ +import { Schema } from "shexj"; + +/** + * ============================================================================= + * postSchema: ShexJ Schema for post + * ============================================================================= + */ +export const postSchema: Schema = { + type: "Schema", + shapes: [ + { + id: "https://example.com/PostSh", + type: "ShapeDecl", + shapeExpr: { + type: "Shape", + expression: { + type: "EachOf", + expressions: [ + { + type: "TripleConstraint", + predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + valueExpr: { + type: "NodeConstraint", + values: [ + "http://schema.org/SocialMediaPosting", + "http://schema.org/CreativeWork", + "http://schema.org/Thing", + ], + }, + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/articleBody", + valueExpr: { + type: "NodeConstraint", + datatype: "http://www.w3.org/2001/XMLSchema#string", + }, + min: 0, + max: 1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "articleBody", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The actual body of the article. ", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/uploadDate", + valueExpr: { + type: "NodeConstraint", + datatype: "http://www.w3.org/2001/XMLSchema#date", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "uploadDate", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "Date when this media object was uploaded to this site.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/image", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: 1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "image", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A media object that encodes this CreativeWork. This property is a synonym for encoding.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/publisher", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "publisher", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The publisher of the creative work.", + }, + }, + ], + }, + ], + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "SocialMediaPost", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A post to a social media platform, including blog posts, tweets, Facebook posts, etc.", + }, + }, + ], + }, + }, + ], +}; diff --git a/packages/connected-solid/test/.ldo/post.shapeTypes.ts b/packages/connected-solid/test/.ldo/post.shapeTypes.ts new file mode 100644 index 0000000..4c50683 --- /dev/null +++ b/packages/connected-solid/test/.ldo/post.shapeTypes.ts @@ -0,0 +1,19 @@ +import { ShapeType } from "@ldo/ldo"; +import { postSchema } from "./post.schema"; +import { postContext } from "./post.context"; +import { PostSh } from "./post.typings"; + +/** + * ============================================================================= + * LDO ShapeTypes post + * ============================================================================= + */ + +/** + * PostSh ShapeType + */ +export const PostShShapeType: ShapeType = { + schema: postSchema, + shape: "https://example.com/PostSh", + context: postContext, +}; diff --git a/packages/connected-solid/test/.ldo/post.typings.ts b/packages/connected-solid/test/.ldo/post.typings.ts new file mode 100644 index 0000000..9ebaf71 --- /dev/null +++ b/packages/connected-solid/test/.ldo/post.typings.ts @@ -0,0 +1,45 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * Typescript Typings for post + * ============================================================================= + */ + +/** + * PostSh Type + */ +export interface PostSh { + "@id"?: string; + "@context"?: ContextDefinition; + type: + | { + "@id": "SocialMediaPosting"; + } + | { + "@id": "CreativeWork"; + } + | { + "@id": "Thing"; + }; + /** + * The actual body of the article. + */ + articleBody?: string; + /** + * Date when this media object was uploaded to this site. + */ + uploadDate: string; + /** + * A media object that encodes this CreativeWork. This property is a synonym for encoding. + */ + image?: { + "@id": string; + }; + /** + * The publisher of the creative work. + */ + publisher: { + "@id": string; + }; +} diff --git a/packages/connected-solid/test/Integration.test.ts b/packages/connected-solid/test/Integration.test.ts new file mode 100644 index 0000000..4c63a19 --- /dev/null +++ b/packages/connected-solid/test/Integration.test.ts @@ -0,0 +1,2264 @@ +import type { App } from "@solid/community-server"; +import type { + Container, + ContainerUri, + Leaf, + LeafUri, + SolidLdoDataset, + UpdateResultError, +} from "../src"; +import { + changeData, + commitData, + createSolidLdoDataset, + SolidLdoTransactionDataset, +} from "../src"; +import { ROOT_CONTAINER, WEB_ID, createApp } from "./solidServer.helper"; +import { + namedNode, + quad as createQuad, + literal, + defaultGraph, +} from "@rdfjs/data-model"; +import type { CreateSuccess } from "../src/requester/results/success/CreateSuccess"; +import type { AggregateSuccess } from "../src/requester/results/success/SuccessResult"; +import type { + IgnoredInvalidUpdateSuccess, + UpdateDefaultGraphSuccess, + UpdateSuccess, +} from "../src/requester/results/success/UpdateSuccess"; +import type { + ResourceResult, + ResourceSuccess, +} from "../src/resource/resourceResult/ResourceResult"; +import type { + AggregateError, + UnexpectedResourceError, +} from "../src/requester/results/error/ErrorResult"; +import type { InvalidUriError } from "../src/requester/results/error/InvalidUriError"; +import { Buffer } from "buffer"; +import { PostShShapeType } from "./.ldo/post.shapeTypes"; +import type { + ServerHttpError, + UnauthenticatedHttpError, + UnexpectedHttpError, +} from "../src/requester/results/error/HttpErrorResult"; +import type { NoncompliantPodError } from "../src/requester/results/error/NoncompliantPodError"; +import type { GetWacRuleSuccess } from "../src/resource/wac/results/GetWacRuleSuccess"; +import type { WacRule } from "../src/resource/wac/WacRule"; +import type { GetStorageContainerFromWebIdSuccess } from "../src/requester/results/success/CheckRootContainerSuccess"; +import { generateAuthFetch } from "./authFetch.helper"; +import { wait } from "./utils.helper"; +import fs from "fs/promises"; +import path from "path"; + +const TEST_CONTAINER_SLUG = "test_ldo/"; +const TEST_CONTAINER_URI = + `${ROOT_CONTAINER}${TEST_CONTAINER_SLUG}` as ContainerUri; +const SAMPLE_DATA_URI = `${TEST_CONTAINER_URI}sample.ttl` as LeafUri; +const SAMPLE2_DATA_SLUG = "sample2.ttl"; +const SAMPLE2_DATA_URI = `${TEST_CONTAINER_URI}${SAMPLE2_DATA_SLUG}` as LeafUri; +const SAMPLE_BINARY_URI = `${TEST_CONTAINER_URI}sample.txt` as LeafUri; +const SAMPLE2_BINARY_SLUG = `sample2.txt`; +const SAMPLE2_BINARY_URI = + `${TEST_CONTAINER_URI}${SAMPLE2_BINARY_SLUG}` as LeafUri; +const SAMPLE_CONTAINER_URI = + `${TEST_CONTAINER_URI}sample_container/` as ContainerUri; +const SAMPLE_PROFILE_URI = `${TEST_CONTAINER_URI}profile.ttl` as LeafUri; +const SPIDER_MAN_TTL = `@base . +@prefix rdf: . +@prefix rdfs: . +@prefix foaf: . +@prefix rel: . + +<#green-goblin> + rel:enemyOf <#spiderman> ; + a foaf:Person ; # in the context of the Marvel universe + foaf:name "Green Goblin" . + +<#spiderman> + rel:enemyOf <#green-goblin> ; + a foaf:Person ; + foaf:name "Spiderman", "Человек-паук"@ru .`; +const TEST_CONTAINER_TTL = `@prefix dc: . +@prefix ldp: . +@prefix posix: . +@prefix xsd: . + +<> "sample.txt"; + a ldp:Container, ldp:BasicContainer, ldp:Resource; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. + a ldp:Resource, ; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. + a ldp:Resource, ; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. +<> posix:mtime 1697810234; + ldp:contains , . + posix:mtime 1697810234; + posix:size 522. + posix:mtime 1697810234; + posix:size 10.`; +const TEST_CONTAINER_ACL_URI = `${TEST_CONTAINER_URI}.acl`; +const TEST_CONTAINER_ACL = `<#b30e3fd1-b5a8-4763-ad9d-e95de9cf7933> a ; + <${TEST_CONTAINER_URI}>; + <${TEST_CONTAINER_URI}>; + , , , ; + <${WEB_ID}>; + , .`; +const SAMPLE_PROFILE_TTL = ` +@prefix pim: . + +<${SAMPLE_PROFILE_URI}> pim:storage , . +`; + +async function testRequestLoads( + request: () => Promise, + loadingResource: Leaf | Container, + loadingValues: Partial<{ + isLoading: boolean; + isCreating: boolean; + isReading: boolean; + isUploading: boolean; + isReloading: boolean; + isDeleting: boolean; + isUpdating: boolean; + isDoingInitialFetch: boolean; + }>, +): Promise { + const allLoadingValues = { + isLoading: false, + isCreating: false, + isReading: false, + isUploading: false, + isReloading: false, + isDeleting: false, + isUpdating: false, + isDoingInitialFetch: false, + ...loadingValues, + }; + const [returnVal] = await Promise.all([ + request(), + (async () => { + Object.entries(allLoadingValues).forEach(([key, value]) => { + if ( + loadingResource.type === "container" && + (key === "isUploading" || key === "isUpdating") + ) { + return; + } + expect(loadingResource[key]()).toBe(value); + }); + })(), + ]); + return returnVal; +} + +describe("Integration", () => { + let app: App; + let authFetch: typeof fetch; + let fetchMock: jest.Mock< + Promise, + [input: RequestInfo | URL, init?: RequestInit | undefined] + >; + let solidLdoDataset: SolidLdoDataset; + + let previousJestId: string | undefined; + let previousNodeEnv: string | undefined; + beforeAll(async () => { + // Remove Jest ID so that community solid server doesn't use the Jest Import + previousJestId = process.env.JEST_WORKER_ID; + previousNodeEnv = process.env.NODE_ENV; + delete process.env.JEST_WORKER_ID; + process.env.NODE_ENV = "other_test"; + // Start up the server + app = await createApp(); + await app.start(); + + authFetch = await generateAuthFetch(); + }); + + afterAll(async () => { + app.stop(); + process.env.JEST_WORKER_ID = previousJestId; + process.env.NODE_ENV = previousNodeEnv; + const testDataPath = path.join(__dirname, "./data"); + await fs.rm(testDataPath, { recursive: true, force: true }); + }); + + beforeEach(async () => { + fetchMock = jest.fn(authFetch); + solidLdoDataset = createSolidLdoDataset({ fetch: fetchMock }); + // Create a new document called sample.ttl + await authFetch(ROOT_CONTAINER, { + method: "POST", + headers: { + link: '; rel="type"', + slug: TEST_CONTAINER_SLUG, + }, + }); + await authFetch(TEST_CONTAINER_ACL_URI, { + method: "PUT", + headers: { + "content-type": "text/turtle", + }, + body: TEST_CONTAINER_ACL, + }); + await Promise.all([ + authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "sample.ttl" }, + body: SPIDER_MAN_TTL, + }), + authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/plain", slug: "sample.txt" }, + body: "some text.", + }), + authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "profile.ttl" }, + body: SAMPLE_PROFILE_TTL, + }), + ]); + }); + + afterEach(async () => { + await Promise.all([ + authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }), + authFetch(SAMPLE2_DATA_URI, { + method: "DELETE", + }), + authFetch(SAMPLE_BINARY_URI, { + method: "DELETE", + }), + authFetch(SAMPLE2_BINARY_URI, { + method: "DELETE", + }), + authFetch(SAMPLE_PROFILE_URI, { + method: "DELETE", + }), + authFetch(SAMPLE_CONTAINER_URI, { + method: "DELETE", + }), + ]); + await authFetch(TEST_CONTAINER_URI, { + method: "DELETE", + }); + }); + + /** + * General + */ + describe("General", () => { + it("Does not include the hash when creating a resource", () => { + const resource = solidLdoDataset.getResource( + "https://example.com/thing#hash", + ); + expect(resource.uri).toBe("https://example.com/thing"); + }); + }); + + /** + * Read + */ + describe("read", () => { + it("Reads a data leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.type).toBe("dataReadSuccess"); + expect( + solidLdoDataset.match( + namedNode("http://example.org/#spiderman"), + namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), + namedNode("http://example.org/#green-goblin"), + ).size, + ).toBe(1); + expect(resource.isBinary()).toBe(false); + expect(resource.isDataResource()).toBe(true); + expect(resource.isPresent()).toBe(true); + }); + + it("Auto reads a resource", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI, { + autoLoad: true, + }); + // Wait until the resource is auto-loaded + await new Promise((resolve) => { + const interval = setInterval(() => { + if (!resource.isReading()) { + clearInterval(interval); + resolve(); + } + }, 250); + }); + expect( + solidLdoDataset.match( + namedNode("http://example.org/#spiderman"), + namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), + namedNode("http://example.org/#green-goblin"), + ).size, + ).toBe(1); + }); + + it("Reads a container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.type).toBe("containerReadSuccess"); + expect(resource.children().length).toBe(3); + }); + + it("Reads a binary leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.type).toBe("binaryReadSuccess"); + expect(resource.isBinary()).toBe(true); + expect(await resource.getBlob()?.text()).toBe("some text."); + }); + + it("Returns an absent result if the document doesn't exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.type).toBe("absentReadSuccess"); + if (result.type !== "absentReadSuccess") return; + expect(result.resource.isAbsent()).toBe(true); + }); + + it("Returns an ServerError when an 500 error is returned", async () => { + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 500 })); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("Returns an Unauthorized error if a 403 error is returned", async () => { + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 403 })); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + expect(result.type).toBe("unauthorizedError"); + }); + + it("Returns an UnauthenticatedError on an 401 error is returned", async () => { + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 401 })); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + expect(result.type).toBe("unauthenticatedError"); + }); + + it("Returns an UnexpectedHttpError on a strange number error is returned", async () => { + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 3942 })); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + expect(result.type).toBe("unexpectedHttpError"); + }); + + it("Returns a NoncompliantPod error when no content type is returned", async () => { + fetchMock.mockResolvedValueOnce( + new Response(undefined, { status: 200, headers: {} }), + ); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + if (!result.isError) return; + expect(result.type).toBe("noncompliantPodError"); + expect(result.message).toMatch( + /\Response from .* is not compliant with the Solid Specification: Resource requests must return a content-type header\./, + ); + }); + + it("Returns a NoncompliantPod error if invalid turtle is provided", async () => { + fetchMock.mockResolvedValueOnce( + new Response("Error", { + status: 200, + headers: new Headers({ "content-type": "text/turtle" }), + }), + ); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + if (!result.isError) return; + expect(result.type).toBe("noncompliantPodError"); + expect(result.message).toMatch( + /\Response from .* is not compliant with the Solid Specification: Request returned noncompliant turtle: Unexpected "Error" on line 1\./, + ); + }); + + it("Parses Turtle even when the content type contains parameters", async () => { + fetchMock.mockResolvedValueOnce( + new Response(SPIDER_MAN_TTL, { + status: 200, + headers: new Headers({ "content-type": "text/turtle;charset=utf-8" }), + }), + ); + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(false); + if (result.isError) return; + expect(result.type).toBe("dataReadSuccess"); + }); + + it("Returns an UnexpectedResourceError if an unknown error is triggered", async () => { + fetchMock.mockRejectedValueOnce(new Error("Something happened.")); + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(true); + if (!result.isError) return; + expect(result.type).toBe("unexpectedResourceError"); + expect(result.message).toBe("Something happened."); + }); + + it("Does not return an error if there is no link header for a container request", async () => { + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 200, + headers: new Headers({ "content-type": "text/turtle" }), + }), + ); + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads(() => resource.read(), resource, { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }); + expect(result.isError).toBe(false); + if (result.isError) return; + expect(result.resource.isRootContainer()).toBe(false); + }); + + it("knows nothing about a leaf resource if it is not fetched", () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + expect(resource.isBinary()).toBe(undefined); + expect(resource.isDataResource()).toBe(undefined); + expect(resource.isUnfetched()).toBe(true); + expect(resource.isPresent()).toBe(undefined); + }); + + it("batches the read request when a read request is currently happening", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const [result, result1] = await Promise.all([ + resource.read(), + resource.read(), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.type).toBe("dataReadSuccess"); + expect(result1.type).toBe("dataReadSuccess"); + }); + + it("batches the read request when a read request is in queue", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const [, result, result1] = await Promise.all([ + resource.createAndOverwrite(), + resource.read(), + resource.read(), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(result.type).toBe("dataReadSuccess"); + expect(result1.type).toBe("dataReadSuccess"); + }); + }); + + /** + * readIfUnfetched + */ + describe("readIfUnfetched", () => { + it("reads an unfetched container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.readIfUnfetched(), + resource, + { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }, + ); + expect(result.type).toBe("containerReadSuccess"); + expect(resource.children().length).toBe(3); + }); + + it("reads an unfetched leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await testRequestLoads( + () => resource.readIfUnfetched(), + resource, + { + isLoading: true, + isReading: true, + isDoingInitialFetch: true, + }, + ); + expect(result.type).toBe("dataReadSuccess"); + expect( + solidLdoDataset.match( + namedNode("http://example.org/#spiderman"), + namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), + namedNode("http://example.org/#green-goblin"), + ).size, + ).toBe(1); + }); + + it("returns a cached existing container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + fetchMock.mockClear(); + const result = await resource.readIfUnfetched(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.type).toBe("containerReadSuccess"); + expect(resource.children().length).toBe(3); + }); + + it("returns a cached existing data leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + await resource.read(); + fetchMock.mockClear(); + const result = await resource.readIfUnfetched(); + expect(result.type).toBe("dataReadSuccess"); + expect( + solidLdoDataset.match( + namedNode("http://example.org/#spiderman"), + namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), + namedNode("http://example.org/#green-goblin"), + ).size, + ).toBe(1); + }); + + it("returns a cached existing binary leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + await resource.read(); + fetchMock.mockClear(); + const result = await resource.readIfUnfetched(); + expect(result.type).toBe("binaryReadSuccess"); + }); + + it("returns a cached absent container", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_CONTAINER_URI); + await resource.read(); + fetchMock.mockClear(); + const result = await resource.readIfUnfetched(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.type).toBe("absentReadSuccess"); + }); + + it("returns a cached absent leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + await resource.read(); + fetchMock.mockClear(); + const result = await resource.readIfUnfetched(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.type).toBe("absentReadSuccess"); + }); + }); + + /** + * Get Root Container + */ + describe("rootContainer", () => { + it("Finds the root container", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_BINARY_URI); + const result = await resource.getRootContainer(); + expect(result.type).toBe("container"); + if (result.type !== "container") return; + expect(result.uri).toBe(ROOT_CONTAINER); + expect(result.isRootContainer()).toBe(true); + }); + + it("Returns an error if there is no root container", async () => { + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 200, + headers: new Headers({ "content-type": "text/turtle" }), + }), + ); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 200, + headers: new Headers({ "content-type": "text/turtle" }), + }), + ); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 200, + headers: new Headers({ "content-type": "text/turtle" }), + }), + ); + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.getRootContainer(); + expect(result.isError).toBe(true); + if (!result.isError) return; + expect(result.type).toBe("noRootContainerError"); + expect(result.message).toMatch(/\.* has not root container\./); + }); + + it("An error to be returned if a common http error is encountered", async () => { + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 500, + }), + ); + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.getRootContainer(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("Returns an UnexpectedResourceError if an unknown error is triggered", async () => { + fetchMock.mockRejectedValueOnce(new Error("Something happened.")); + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.getRootContainer(); + expect(result.isError).toBe(true); + if (!result.isError) return; + expect(result.type).toBe("unexpectedResourceError"); + expect(result.message).toBe("Something happened."); + }); + + it("returns a NonCompliantPodError when there is no root", async () => { + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 200, + headers: new Headers({ + "content-type": "text/turtle", + link: '; rel="type"', + }), + }), + ); + const resource = solidLdoDataset.getResource(ROOT_CONTAINER); + const result = await resource.getRootContainer(); + expect(result.isError).toBe(true); + expect(result.type).toBe("noRootContainerError"); + }); + }); + + /** + * Get Storage From WebId + */ + describe("getStorageFromWebId", () => { + it("Gets storage when a pim:storage field isn't present", async () => { + const result = await solidLdoDataset.getStorageFromWebId(SAMPLE_DATA_URI); + expect(result.type).toBe("getStorageContainerFromWebIdSuccess"); + const realResult = result as GetStorageContainerFromWebIdSuccess; + expect(realResult.storageContainers.length).toBe(1); + expect(realResult.storageContainers[0].uri).toBe(ROOT_CONTAINER); + }); + + it("Gets storage when a pim:storage field is present", async () => { + const result = + await solidLdoDataset.getStorageFromWebId(SAMPLE_PROFILE_URI); + expect(result.type).toBe("getStorageContainerFromWebIdSuccess"); + const realResult = result as GetStorageContainerFromWebIdSuccess; + expect(realResult.storageContainers.length).toBe(2); + expect(realResult.storageContainers[0].uri).toBe( + "https://example.com/A/", + ); + expect(realResult.storageContainers[1].uri).toBe( + "https://example.com/B/", + ); + }); + + it("Passes any errors returned from the read method", async () => { + fetchMock.mockRejectedValueOnce(new Error("Something happened.")); + const result = await solidLdoDataset.getStorageFromWebId(SAMPLE_DATA_URI); + expect(result.isError).toBe(true); + }); + + it("Passes any errors returned from the getRootContainer method", async () => { + fetchMock.mockResolvedValueOnce(new Response("")); + fetchMock.mockRejectedValueOnce(new Error("Something happened.")); + const result = await solidLdoDataset.getStorageFromWebId(SAMPLE_DATA_URI); + expect(result.isError).toBe(true); + }); + }); + + /** + * =========================================================================== + * Create + * =========================================================================== + */ + describe("createAndOverwrite", () => { + it("creates a document that doesn't exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.createAndOverwrite(), + resource, + { + isLoading: true, + isCreating: true, + }, + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_DATA_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE2_DATA_URI), + ).toBe(true); + }); + + it("creates a data resource that doesn't exist while overwriting", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.createAndOverwrite(), + resource, + { + isLoading: true, + isCreating: true, + }, + ); + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(true); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE_DATA_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE_DATA_URI), + ).toBe(true); + }); + + it("creates a container", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_CONTAINER_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.createAndOverwrite(), + resource, + { + isLoading: true, + isCreating: true, + }, + ); + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE_CONTAINER_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container + .children() + .some((child) => child.uri === SAMPLE_CONTAINER_URI), + ).toBe(true); + }); + + it("returns and error if creating a container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 500, + }), + ); + const result = await resource.createAndOverwrite(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("returns a delete error if delete failed", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 500, + }), + ); + const result = await resource.createAndOverwrite(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("returns an error if the create fetch fails", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockImplementationOnce(async (...args) => { + return authFetch(...args); + }); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 500, + }), + ); + const result = await resource.createAndOverwrite(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("returns an unexpected error if some unknown error is triggered", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockImplementationOnce(async (...args) => { + return authFetch(...args); + }); + fetchMock.mockImplementationOnce(async () => { + throw new Error("Some Unknown"); + }); + const result = await resource.createAndOverwrite(); + expect(result.isError).toBe(true); + expect(result.type).toBe("unexpectedResourceError"); + }); + + it("batches the create request while waiting on another request", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const [, result1, result2] = await Promise.all([ + resource.read(), + resource.createAndOverwrite(), + resource.createAndOverwrite(), + ]); + + expect(result1.type).toBe("createSuccess"); + expect(result2.type).toBe("createSuccess"); + // 1 for read, 1 for delete in createAndOverwrite, 1 for create + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("batches the create request while waiting on a similar request", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const [result1, result2] = await Promise.all([ + resource.createAndOverwrite(), + resource.createAndOverwrite(), + ]); + + expect(result1.type).toBe("createSuccess"); + expect(result2.type).toBe("createSuccess"); + // 1 for delete in createAndOverwrite, 1 for create + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("createIfAbsent", () => { + it("creates a data resource that doesn't exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.createIfAbsent(), + resource, + { + isLoading: true, + isCreating: true, + }, + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_DATA_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE2_DATA_URI), + ).toBe(true); + }); + + it("doesn't overwrite a resources that does exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.createIfAbsent(), + resource, + { + isLoading: true, + isCreating: true, + }, + ); + + expect(result.type).toBe("dataReadSuccess"); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE_DATA_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE_DATA_URI), + ).toBe(true); + }); + + it("creates a container that doesn't exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_CONTAINER_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => resource.createIfAbsent(), + resource, + { + isLoading: true, + isCreating: true, + }, + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE_CONTAINER_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container + .children() + .some((child) => child.uri === SAMPLE_CONTAINER_URI), + ).toBe(true); + }); + + it("returns an error if creating a container", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_CONTAINER_URI); + fetchMock.mockResolvedValueOnce( + new Response(SAMPLE_CONTAINER_URI, { + status: 500, + }), + ); + const result = await resource.createIfAbsent(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("returns an error if creating a leaf", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response(SAMPLE2_DATA_URI, { + status: 500, + }), + ); + const result = await resource.createIfAbsent(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + }); + + /** + * Delete + */ + describe("deleteResource", () => { + it("returns an unexpected http error if an unexpected value is returned", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 214, + }), + ); + const result = await resource.delete(); + expect(result.isError).toBe(true); + expect(result.type).toBe("unexpectedHttpError"); + }); + + it("returns an unexpected resource error if an unknown error is triggered", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockImplementationOnce(async () => { + throw new Error("Some unknwon"); + }); + const result = await resource.delete(); + expect(result.isError).toBe(true); + expect(result.type).toBe("unexpectedResourceError"); + }); + + it("deletes a container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.delete(); + expect(result.type === "deleteSuccess"); + }); + + it("returns an error on container read when deleting a container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockImplementation(async (input, init) => { + if ( + (init?.method === "get" || !init?.method) && + input === TEST_CONTAINER_URI + ) { + return new Response(SAMPLE_DATA_URI, { + status: 500, + }); + } + return authFetch(input, init); + }); + const result = await resource.delete(); + expect(result.isError).toBe(true); + expect(result.type).toBe("aggregateError"); + const aggregateError = result as AggregateError< + | ServerHttpError + | UnexpectedHttpError + | UnauthenticatedHttpError + | UnexpectedResourceError + | NoncompliantPodError + >; + expect(aggregateError.errors[0].type).toBe("serverError"); + }); + + it("returns an error on child delete when deleting a container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockImplementation(async (input, init) => { + if (init?.method === "delete" && input === SAMPLE_DATA_URI) { + return new Response(SAMPLE_DATA_URI, { + status: 500, + }); + } + return authFetch(input, init); + }); + const result = await resource.delete(); + expect(result.isError).toBe(true); + expect(result.type).toBe("aggregateError"); + const aggregateError = result as AggregateError< + | ServerHttpError + | UnexpectedHttpError + | UnauthenticatedHttpError + | UnexpectedResourceError + | NoncompliantPodError + >; + expect(aggregateError.errors[0].type).toBe("serverError"); + }); + + it("returns an error on container delete when deleting a container", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockImplementation(async (input, init) => { + if (init?.method === "delete" && input === TEST_CONTAINER_URI) { + return new Response(SAMPLE_DATA_URI, { + status: 500, + }); + } + return authFetch(input, init); + }); + const result = await resource.delete(); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + }); + + /** + * Update + */ + describe("updateDataResource", () => { + const normanQuad = createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Norman Osborn"), + namedNode(SAMPLE_DATA_URI), + ); + + const goblinQuad = createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Green Goblin"), + namedNode(SAMPLE_DATA_URI), + ); + + it("applies changes to a Pod", async () => { + const result = await testRequestLoads( + () => { + const transaction = solidLdoDataset.startTransaction(); + transaction.add(normanQuad); + transaction.delete(goblinQuad); + return transaction.commitToPod(); + }, + solidLdoDataset.getResource(SAMPLE_DATA_URI), + { + isLoading: true, + isUpdating: true, + }, + ); + expect(result.type).toBe("aggregateSuccess"); + const aggregateSuccess = result as AggregateSuccess< + ResourceSuccess + >; + expect(aggregateSuccess.results.length).toBe(1); + expect(aggregateSuccess.results[0].type === "updateSuccess").toBe(true); + expect(solidLdoDataset.has(normanQuad)).toBe(true); + expect(solidLdoDataset.has(goblinQuad)).toBe(false); + }); + + it("applies only remove changes to the Pod", async () => { + const result = await testRequestLoads( + () => { + const transaction = solidLdoDataset.startTransaction(); + transaction.delete(goblinQuad); + return transaction.commitToPod(); + }, + solidLdoDataset.getResource(SAMPLE_DATA_URI), + { + isLoading: true, + isUpdating: true, + }, + ); + expect(result.type).toBe("aggregateSuccess"); + const aggregateSuccess = result as AggregateSuccess< + ResourceSuccess + >; + expect(aggregateSuccess.results.length).toBe(1); + expect(aggregateSuccess.results[0].type === "updateSuccess").toBe(true); + expect(solidLdoDataset.has(goblinQuad)).toBe(false); + }); + + it("handles an HTTP error", async () => { + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 500 })); + + const transaction = solidLdoDataset.startTransaction(); + transaction.add(normanQuad); + transaction.delete(goblinQuad); + const result = await transaction.commitToPod(); + + expect(result.isError).toBe(true); + expect(result.type).toBe("aggregateError"); + const aggregateError = result as AggregateError< + UpdateResultError | InvalidUriError + >; + expect(aggregateError.errors.length).toBe(1); + expect(aggregateError.errors[0].type).toBe("serverError"); + }); + + it("handles an unknown request", async () => { + fetchMock.mockImplementationOnce(() => { + throw new Error("Some Error"); + }); + const transaction = solidLdoDataset.startTransaction(); + transaction.add(normanQuad); + transaction.delete(goblinQuad); + const result = await transaction.commitToPod(); + expect(result.isError).toBe(true); + expect(result.type).toBe("aggregateError"); + const aggregateError = result as AggregateError< + UpdateResultError | InvalidUriError + >; + expect(aggregateError.errors.length).toBe(1); + expect(aggregateError.errors[0].type).toBe("unexpectedResourceError"); + }); + + it("ignores update when trying to update a container", async () => { + const badContainerQuad = createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Norman Osborn"), + namedNode(SAMPLE_CONTAINER_URI), + ); + const transaction = solidLdoDataset.startTransaction(); + transaction.add(badContainerQuad); + const result = await transaction.commitToPod(); + expect(result.isError).toBe(false); + expect(result.type).toBe("aggregateSuccess"); + const aggregateSuccess = result as AggregateSuccess< + UpdateSuccess | IgnoredInvalidUpdateSuccess + >; + expect(aggregateSuccess.results.length).toBe(1); + expect(aggregateSuccess.results[0].type).toBe( + "ignoredInvalidUpdateSuccess", + ); + }); + + it("writes to the default graph without fetching", async () => { + const defaultGraphQuad = createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Norman Osborn"), + defaultGraph(), + ); + const transaction = solidLdoDataset.startTransaction(); + transaction.add(defaultGraphQuad); + const result = await transaction.commitToPod(); + expect(result.type).toBe("aggregateSuccess"); + const aggregateSuccess = result as AggregateSuccess< + ResourceSuccess + >; + expect(aggregateSuccess.results.length).toBe(1); + expect(aggregateSuccess.results[0].type).toBe( + "updateDefaultGraphSuccess", + ); + expect( + solidLdoDataset.has( + createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Norman Osborn"), + defaultGraph(), + ), + ), + ).toBe(true); + }); + + it("batches data update changes", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + + const transaction1 = solidLdoDataset.startTransaction(); + transaction1.delete(goblinQuad); + const transaction2 = solidLdoDataset.startTransaction(); + transaction2.add(normanQuad); + + const [, updateResult1, updateResult2] = await Promise.all([ + resource.read(), + transaction1.commitToPod(), + transaction2.commitToPod(), + ]); + expect(updateResult1.type).toBe("aggregateSuccess"); + expect(updateResult2.type).toBe("aggregateSuccess"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect( + solidLdoDataset.has( + createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Norman Osborn"), + namedNode(SAMPLE_DATA_URI), + ), + ), + ).toBe(true); + expect( + solidLdoDataset.has( + createQuad( + namedNode("http://example.org/#green-goblin"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Green Goblin"), + namedNode(SAMPLE_DATA_URI), + ), + ), + ).toBe(false); + }); + }); + + it("allows a transaction on a transaction", () => { + const transaction = solidLdoDataset.startTransaction(); + const transaction2 = transaction.startTransaction(); + expect(transaction2).toBeInstanceOf(SolidLdoTransactionDataset); + }); + + /** + * =========================================================================== + * Upload + * =========================================================================== + */ + describe("uploadAndOverwrite", () => { + it("uploads a document that doesn't exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_BINARY_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => + resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ), + resource, + { + isLoading: true, + isUploading: true, + }, + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_BINARY_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE2_BINARY_URI), + ).toBe(true); + expect(resource.getMimeType()).toBe("text/plain"); + expect(resource.isBinary()).toBe(true); + expect(resource.isDataResource()).toBe(false); + }); + + it("creates a binary resource that doesn't exist while overwriting", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => + resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ), + resource, + { + isLoading: true, + isUploading: true, + }, + ); + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(true); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE_BINARY_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE_BINARY_URI), + ).toBe(true); + }); + + it("returns a delete error if delete failed", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 500, + }), + ); + const result = await resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("returns an error if the create fetch fails", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + fetchMock.mockImplementationOnce(async (...args) => { + return authFetch(...args); + }); + fetchMock.mockResolvedValueOnce( + new Response(TEST_CONTAINER_TTL, { + status: 500, + }), + ); + const result = await resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + + it("returns an unexpected error if some unknown error is triggered", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + fetchMock.mockImplementationOnce(async (...args) => { + return authFetch(...args); + }); + fetchMock.mockImplementationOnce(async () => { + throw new Error("Some Unknown"); + }); + const result = await resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ); + expect(result.isError).toBe(true); + expect(result.type).toBe("unexpectedResourceError"); + }); + + it("batches the upload request while waiting on another request", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const [, result1, result2] = await Promise.all([ + resource.read(), + resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ), + resource.uploadAndOverwrite( + Buffer.from("some text 2.") as unknown as Blob, + "text/plain", + ), + ]); + + expect(result1.type).toBe("createSuccess"); + expect(result2.type).toBe("createSuccess"); + // 1 for read, 1 for delete in createAndOverwrite, 1 for create + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(resource.getBlob()?.toString()).toBe("some text 2."); + }); + + it("batches the upload request while waiting on a similar request", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const [result1, result2] = await Promise.all([ + resource.uploadAndOverwrite( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ), + resource.uploadAndOverwrite( + Buffer.from("some text 2.") as unknown as Blob, + "text/plain", + ), + ]); + + expect(result1.type).toBe("createSuccess"); + expect(result2.type).toBe("createSuccess"); + // 1 for delete in createAndOverwrite, 1 for create + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(resource.getBlob()?.toString()).toBe("some text 2."); + }); + }); + + describe("uploadIfAbsent", () => { + it("creates a binary resource that doesn't exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_BINARY_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => + resource.uploadIfAbsent( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ), + resource, + { + isLoading: true, + isUploading: true, + }, + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as CreateSuccess; + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_BINARY_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE2_BINARY_URI), + ).toBe(true); + }); + + it("doesn't overwrite a binary resource that does exist", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_BINARY_URI); + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await testRequestLoads( + () => + resource.uploadIfAbsent( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ), + resource, + { + isLoading: true, + isUploading: true, + }, + ); + + expect(result.type).toBe("binaryReadSuccess"); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE_BINARY_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + container.children().some((child) => child.uri === SAMPLE_BINARY_URI), + ).toBe(true); + }); + + it("returns an error if an error is encountered", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_BINARY_URI); + fetchMock.mockResolvedValueOnce( + new Response(SAMPLE2_BINARY_URI, { + status: 500, + }), + ); + const result = await resource.uploadIfAbsent( + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ); + expect(result.isError).toBe(true); + expect(result.type).toBe("serverError"); + }); + }); + + /** + * =========================================================================== + * Methods + * =========================================================================== + */ + describe("methods", () => { + it("creates a data object for a specific subject", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const post = solidLdoDataset.createData( + PostShShapeType, + "https://example.com/subject", + resource, + ); + post.type = { "@id": "CreativeWork" }; + expect(post.type["@id"]).toBe("CreativeWork"); + const result = await commitData(post); + expect(result.type).toBe("aggregateSuccess"); + expect( + solidLdoDataset.has( + createQuad( + namedNode("https://example.com/subject"), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://schema.org/CreativeWork"), + namedNode(SAMPLE_DATA_URI), + ), + ), + ).toBe(true); + }); + + it("handles an error when committing data", async () => { + fetchMock.mockResolvedValueOnce( + new Response(SAMPLE_DATA_URI, { + status: 500, + }), + ); + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const post = solidLdoDataset.createData( + PostShShapeType, + "https://example.com/subject", + resource, + ); + post.type = { "@id": "CreativeWork" }; + expect(post.type["@id"]).toBe("CreativeWork"); + const result = await commitData(post); + expect(result.isError).toBe(true); + }); + + it("uses changeData to start a transaction", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + solidLdoDataset.add( + createQuad( + namedNode("https://example.com/subject"), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://schema.org/CreativeWork"), + namedNode(SAMPLE_DATA_URI), + ), + ); + const post = solidLdoDataset + .usingType(PostShShapeType) + .fromSubject("https://example.com/subject"); + const cPost = changeData(post, resource); + cPost.type = { "@id": "SocialMediaPosting" }; + expect(cPost.type["@id"]).toBe("SocialMediaPosting"); + const result = await commitData(cPost); + expect(result.isError).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode("https://example.com/subject"), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://schema.org/SocialMediaPosting"), + namedNode(SAMPLE_DATA_URI), + ), + ), + ).toBe(true); + }); + }); + + /** + * =========================================================================== + * Container-Specific Methods + * =========================================================================== + */ + describe("container specific", () => { + it("returns the child with the child method", () => { + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const child = container.child(SAMPLE2_DATA_SLUG); + expect(child.uri).toBe(SAMPLE2_DATA_URI); + }); + + it("runs createAndOverwrite for a child via the createChildAndOverwrite method", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.createChildAndOverwrite(SAMPLE2_DATA_SLUG); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as ResourceResult; + expect(createSuccess.resource.uri).toBe(SAMPLE2_DATA_URI); + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_DATA_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + resource.children().some((child) => child.uri === SAMPLE2_DATA_URI), + ).toBe(true); + }); + + it("runs createIfAbsent for a child via the createChildIfAbsent method", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.createChildIfAbsent(SAMPLE2_DATA_SLUG); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as ResourceResult; + expect(createSuccess.resource.uri).toBe(SAMPLE2_DATA_URI); + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_DATA_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + resource.children().some((child) => child.uri === SAMPLE2_DATA_URI), + ).toBe(true); + }); + + it("runs uploadAndOverwrite for a child via the uploadChildAndOverwrite method", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.uploadChildAndOverwrite( + SAMPLE2_BINARY_SLUG, + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as ResourceResult; + expect(createSuccess.resource.uri).toBe(SAMPLE2_BINARY_URI); + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_BINARY_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + resource.children().some((child) => child.uri === SAMPLE2_BINARY_URI), + ).toBe(true); + }); + + it("runs uploadIfAbsent for a child via the uploadChildIfAbsent method", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.uploadChildIfAbsent( + SAMPLE2_BINARY_SLUG, + Buffer.from("some text.") as unknown as Blob, + "text/plain", + ); + + expect(result.type).toBe("createSuccess"); + const createSuccess = result as ResourceResult; + expect(createSuccess.resource.uri).toBe(SAMPLE2_BINARY_URI); + expect(createSuccess.didOverwrite).toBe(false); + expect( + solidLdoDataset.has( + createQuad( + namedNode(TEST_CONTAINER_URI), + namedNode("http://www.w3.org/ns/ldp#contains"), + namedNode(SAMPLE2_BINARY_URI), + namedNode(TEST_CONTAINER_URI), + ), + ), + ).toBe(true); + expect( + resource.children().some((child) => child.uri === SAMPLE2_BINARY_URI), + ).toBe(true); + }); + }); + + /** + * =========================================================================== + * ACCESS CONTROL + * =========================================================================== + */ + describe("getWacRule", () => { + it("Fetches a wac rules for a container that has a corresponding acl", async () => { + const container = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const wacResult = await container.getWac(); + expect(wacResult.isError).toBe(false); + const wacSuccess = wacResult as GetWacRuleSuccess; + expect(wacSuccess.wacRule.public).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + expect(wacSuccess.wacRule.authenticated).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + expect(wacSuccess.wacRule.agent[WEB_ID]).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + }); + + it("Gets wac rules of a parent resource for a resource that does not have a corresponding acl", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(false); + const wacSuccess = wacResult as GetWacRuleSuccess; + expect(wacSuccess.wacRule.public).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + expect(wacSuccess.wacRule.authenticated).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + expect(wacSuccess.wacRule.agent[WEB_ID]).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + }); + + it("uses cached values for a retrieved resource", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + await resource.getWac(); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(false); + const wacSuccess = wacResult as GetWacRuleSuccess; + expect(wacSuccess.wacRule.public).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + expect(wacSuccess.wacRule.authenticated).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + expect(wacSuccess.wacRule.agent[WEB_ID]).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + }); + + it("returns an error when an error is encountered fetching the aclUri", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 500 })); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); + + it("returns an error when a document is not found", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("notFoundError"); + }); + + it("returns a non-compliant error if a response is returned without a link header", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response("Error", { + status: 200, + }), + ); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("noncompliantPodError"); + expect((wacResult as NoncompliantPodError).message).toBe( + `Response from ${SAMPLE_DATA_URI} is not compliant with the Solid Specification: No link header present in request.`, + ); + }); + + it("returns a non-compliant error if a response is returned without an ACL link", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response("Error", { + status: 200, + headers: { link: `; rel="describedBy"` }, + }), + ); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("noncompliantPodError"); + expect((wacResult as NoncompliantPodError).message).toBe( + `Response from ${SAMPLE_DATA_URI} is not compliant with the Solid Specification: There must be one link with a rel="acl"`, + ); + }); + + it("Returns an UnexpectedResourceError if an unknown error is triggered while getting the wac URI", async () => { + fetchMock.mockRejectedValueOnce(new Error("Something happened.")); + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.getWac(); + expect(result.isError).toBe(true); + if (!result.isError) return; + expect(result.type).toBe("unexpectedResourceError"); + expect(result.message).toBe("Something happened."); + }); + + it("Returns an error if the request to get the ACL fails", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 500 })); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); + + it("Returns a non-compliant error if the root uri has no ACL", () => {}); + + it("Returns an error if the request to the ACL resource returns invalid turtle", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce( + new Response("BAD TURTLE", { status: 200 }), + ); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("noncompliantPodError"); + expect((wacResult as NoncompliantPodError).message).toBe( + `Response from card.acl is not compliant with the Solid Specification: Request returned noncompliant turtle: Unexpected "BAD" on line 1.\nBAD TURTLE`, + ); + }); + + it("Returns an error if there was a problem getting the parent resource", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce(new Response("", { status: 404 })); + fetchMock.mockResolvedValueOnce(new Response("", { status: 500 })); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); + + it("returns a NonCompliantPodError when this is the root resource and it doesn't have an ACL", async () => { + const resource = solidLdoDataset.getResource(ROOT_CONTAINER); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce(new Response("", { status: 404 })); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("noncompliantPodError"); + expect((wacResult as NoncompliantPodError).message).toBe( + `Response from ${ROOT_CONTAINER} is not compliant with the Solid Specification: Resource "${ROOT_CONTAINER}" has no Effective ACL resource`, + ); + }); + }); + + describe("setWacRule", () => { + const newRules: WacRule = { + public: { read: true, write: false, append: false, control: false }, + authenticated: { + read: true, + write: false, + append: true, + control: false, + }, + agent: { + [WEB_ID]: { read: true, write: true, append: true, control: true }, + }, + }; + + it("sets wac rules for a resource that didn't have one before", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.setWac(newRules); + expect(result.isError).toBe(false); + expect(result.type).toBe("setWacRuleSuccess"); + const readResult = await resource.getWac({ ignoreCache: true }); + expect(readResult.isError).toBe(false); + expect(readResult.type).toBe("getWacRuleSuccess"); + const rules = (readResult as GetWacRuleSuccess).wacRule; + expect(rules).toEqual(newRules); + }); + + it("overwrites an existing access control rule", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.setWac(newRules); + expect(result.isError).toBe(false); + expect(result.type).toBe("setWacRuleSuccess"); + const readResult = await resource.getWac({ ignoreCache: true }); + expect(readResult.isError).toBe(false); + expect(readResult.type).toBe("getWacRuleSuccess"); + const rules = (readResult as GetWacRuleSuccess).wacRule; + expect(rules).toEqual(newRules); + }); + + it("Does not write a rule when access is not granted to an agent", async () => { + const moreRules = { + ...newRules, + public: { read: false, write: false, append: false, control: false }, + }; + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.setWac(moreRules); + expect(result.isError).toBe(false); + expect(result.type).toBe("setWacRuleSuccess"); + const readResult = await resource.getWac({ ignoreCache: true }); + expect(readResult.isError).toBe(false); + expect(readResult.type).toBe("getWacRuleSuccess"); + const rules = (readResult as GetWacRuleSuccess).wacRule; + expect(rules).toEqual(moreRules); + }); + + it("returns an error when an error is encountered fetching the aclUri", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 500 })); + const wacResult = await resource.setWac(newRules); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); + + it("Returns an error when the request to write the access rules throws an error", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce(new Response("", { status: 500 })); + const wacResult = await resource.setWac(newRules); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); + }); + + /** + * =========================================================================== + * NOTIFICATION SUBSCRIPTIONS + * =========================================================================== + */ + describe("Notification Subscriptions", () => { + const spidermanNode = namedNode("http://example.org/#spiderman"); + const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name"); + + it("handles notification when a resource is updated", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const subscriptionId = await resource.subscribeToNotifications(); + + expect(resource.isSubscribedToNotifications()).toBe(true); + + await authFetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: 'INSERT DATA { "Peter Parker" . }', + headers: { + "Content-Type": "application/sparql-update", + }, + }); + await wait(1000); + + expect( + solidLdoDataset.match( + spidermanNode, + foafNameNode, + literal("Peter Parker"), + ).size, + ).toBe(1); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + + // Notification is not propogated after unsubscribe + spidermanCallback.mockClear(); + await resource.unsubscribeFromNotifications(subscriptionId); + expect(resource.isSubscribedToNotifications()).toBe(false); + await authFetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: 'INSERT DATA { "Miles Morales" . }', + headers: { + "Content-Type": "application/sparql-update", + }, + }); + await wait(50); + + expect(spidermanCallback).not.toHaveBeenCalled(); + expect( + solidLdoDataset.match( + spidermanNode, + foafNameNode, + literal("Miles Morales"), + ).size, + ).toBe(0); + }); + + it("handles notification when subscribed to a child that is deleted", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const containerCallback = jest.fn(); + solidLdoDataset.addListener( + [namedNode(TEST_CONTAINER_URI), null, null, null], + containerCallback, + ); + + await resource.subscribeToNotifications(); + + await authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }); + await wait(1000); + + expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(0); + expect( + testContainer.children().some((child) => child.uri === SAMPLE_DATA_URI), + ).toBe(false); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect(containerCallback).toHaveBeenCalledTimes(1); + + await resource.unsubscribeFromAllNotifications(); + }); + + it("handles notification when subscribed to a parent with a deleted child", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const containerCallback = jest.fn(); + solidLdoDataset.addListener( + [namedNode(TEST_CONTAINER_URI), null, null, null], + containerCallback, + ); + + await testContainer.subscribeToNotifications(); + + await authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }); + await wait(1000); + + expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(0); + expect( + testContainer.children().some((child) => child.uri === SAMPLE_DATA_URI), + ).toBe(false); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect(containerCallback).toHaveBeenCalledTimes(1); + + await testContainer.unsubscribeFromAllNotifications(); + }); + + it("handles notification when subscribed to a parent with an added child", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const containerCallback = jest.fn(); + solidLdoDataset.addListener( + [namedNode(TEST_CONTAINER_URI), null, null, null], + containerCallback, + ); + + await testContainer.subscribeToNotifications(); + + await authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "sample2.ttl" }, + body: SPIDER_MAN_TTL, + }); + await wait(1000); + + expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(4); + expect( + testContainer + .children() + .some((child) => child.uri === SAMPLE2_DATA_URI), + ).toBe(true); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect(containerCallback).toHaveBeenCalledTimes(1); + + await testContainer.unsubscribeFromAllNotifications(); + }); + + it("returns an error when it cannot subscribe to a notification", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const onError = jest.fn(); + + await app.stop(); + await resource.subscribeToNotifications({ onNotificationError: onError }); + expect(onError).toHaveBeenCalledTimes(2); + await app.start(); + }); + + it("returns an error when the server doesnt support websockets", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const onError = jest.fn(); + + await app.stop(); + const disabledWebsocketsApp = await createApp( + path.join(__dirname, "./configs/server-config-without-websocket.json"), + ); + await disabledWebsocketsApp.start(); + + await resource.subscribeToNotifications({ onNotificationError: onError }); + expect(onError).toHaveBeenCalledTimes(2); + + await disabledWebsocketsApp.stop(); + await app.start(); + }); + + it("attempts to reconnect multiple times before giving up.", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const onError = jest.fn(); + + await app.stop(); + const disabledWebsocketsApp = await createApp( + path.join(__dirname, "./configs/server-config-without-websocket.json"), + ); + await disabledWebsocketsApp.start(); + + await resource.subscribeToNotifications({ onNotificationError: onError }); + + // TODO: This is a bad test because of the wait. Instead inject better + // numbers into the websocket class. + await wait(35000); + + expect(onError).toHaveBeenCalledTimes(14); + expect(onError.mock.calls[1][0].type).toBe( + "disconnectedAttemptingReconnectError", + ); + expect(onError.mock.calls[13][0].type).toBe( + "disconnectedNotAttemptingReconnectError", + ); + + await disabledWebsocketsApp.stop(); + await app.start(); + }); + + it("causes no problems when unsubscribing when not subscribed", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + await resource.unsubscribeFromAllNotifications(); + expect(resource.isSubscribedToNotifications()).toBe(false); + }); + }); +}); diff --git a/packages/connected-solid/test/LeafRequester.test.ts b/packages/connected-solid/test/LeafRequester.test.ts new file mode 100644 index 0000000..72dd179 --- /dev/null +++ b/packages/connected-solid/test/LeafRequester.test.ts @@ -0,0 +1,256 @@ +// import type { App } from "@solid/community-server"; +// import { getAuthenticatedFetch, ROOT_COONTAINER } from "./solidServer.helper"; +// import type { SolidLdoDataset } from "../src/SolidLdoDataset"; +// import { createSolidLdoDataset } from "../src/createSolidLdoDataset"; +// import { LeafRequester } from "../src/requester/LeafRequester"; +// import { namedNode, quad as createQuad } from "@rdfjs/data-model"; + +describe("Leaf Requester", () => { + it("trivial", () => { + expect(true).toBe(true); + }); +}); + +// describe.skip("Leaf Requester", () => { +// let _app: App; +// let authFetch: typeof fetch; +// let fetchMock: typeof fetch; +// let solidLdoDataset: SolidLdoDataset; + +// beforeAll(async () => { +// // Start up the server +// // app = await createApp(); +// // await app.start(); + +// authFetch = await getAuthenticatedFetch(); +// }); + +// beforeEach(async () => { +// fetchMock = jest.fn(authFetch); +// solidLdoDataset = createSolidLdoDataset({ fetch: fetchMock }); +// // Create a new document called sample.ttl +// await Promise.all([ +// authFetch(`${ROOT_COONTAINER}test_leaf/`, { +// method: "POST", +// headers: { "content-type": "text/turtle", slug: "sample.ttl" }, +// body: `@base . +// @prefix rdf: . +// @prefix rdfs: . +// @prefix foaf: . +// @prefix rel: . + +// <#green-goblin> +// rel:enemyOf <#spiderman> ; +// a foaf:Person ; # in the context of the Marvel universe +// foaf:name "Green Goblin" . + +// <#spiderman> +// rel:enemyOf <#green-goblin> ; +// a foaf:Person ; +// foaf:name "Spiderman", "Человек-паук"@ru .`, +// }), +// authFetch(`${ROOT_COONTAINER}test_leaf/`, { +// method: "PUT", +// headers: { "content-type": "text/plain", slug: "sample.txt" }, +// body: `some text.`, +// }), +// ]); +// }); + +// afterEach(async () => { +// await Promise.all([ +// authFetch(`${ROOT_COONTAINER}test_leaf/sample.ttl`, { +// method: "DELETE", +// }), +// authFetch(`${ROOT_COONTAINER}test_leaf/sample2.ttl`, { +// method: "DELETE", +// }), +// authFetch(`${ROOT_COONTAINER}test_leaf/sample.txt`, { +// method: "DELETE", +// }), +// authFetch(`${ROOT_COONTAINER}test_leaf/sample2.txt`, { +// method: "DELETE", +// }), +// ]); +// }); + +// /** +// * =========================================================================== +// * Read +// * =========================================================================== +// */ +// it("reads data", async () => { +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}test_leaf/sample.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.read(); +// expect(result.type).toBe("data"); +// expect( +// solidLdoDataset.match( +// null, +// null, +// null, +// namedNode(`${ROOT_COONTAINER}test_leaf/sample.ttl`), +// ).size, +// ).toBe(7); +// }); + +// it("reads data that doesn't exist", async () => { +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}test_leaf/doesnotexist.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.read(); +// expect(result.type).toBe("absent"); +// }); + +// /** +// * =========================================================================== +// * Create +// * =========================================================================== +// */ +// it("creates a data resource that doesn't exist while not overwriting", async () => { +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}test_leaf/sample2.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.createDataResource(); +// expect(result.type).toBe("data"); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// namedNode("http://www.w3.org/ns/ldp#contains"), +// namedNode(`${ROOT_COONTAINER}test_leaf/sample2.ttl`), +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// ), +// ), +// ).toBe(true); +// }); + +// it("creates a data resource that doesn't exist while overwriting", async () => { +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}test_leaf/sample2.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.createDataResource(true); +// expect(result.type).toBe("data"); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// namedNode("http://www.w3.org/ns/ldp#contains"), +// namedNode(`${ROOT_COONTAINER}test_leaf/sample2.ttl`), +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// ), +// ), +// ).toBe(true); +// }); + +// it("creates a data resource that does exist while not overwriting", async () => { +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}test_leaf/sample.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.createDataResource(); +// expect(result.type).toBe("data"); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode("http://example.org/#spiderman"), +// namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), +// namedNode("http://example.org/#green-goblin"), +// namedNode(`${ROOT_COONTAINER}test_leaf/sample.ttl`), +// ), +// ), +// ).toBe(true); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// namedNode("http://www.w3.org/ns/ldp#contains"), +// namedNode(`${ROOT_COONTAINER}test_leaf/sample.ttl`), +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// ), +// ), +// ).toBe(true); +// }); + +// it("creates a data resource that does exist while overwriting", async () => { +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}test_leaf/sample.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.createDataResource(true); +// expect(result.type).toBe("data"); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode("http://example.org/#spiderman"), +// namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), +// namedNode("http://example.org/#green-goblin"), +// namedNode(`${ROOT_COONTAINER}test_leaf/sample.ttl`), +// ), +// ), +// ).toBe(false); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// namedNode("http://www.w3.org/ns/ldp#contains"), +// namedNode(`${ROOT_COONTAINER}test_leaf/sample.ttl`), +// namedNode(`${ROOT_COONTAINER}test_leaf/`), +// ), +// ), +// ).toBe(true); +// }); + +// /** +// * =========================================================================== +// * Delete +// * =========================================================================== +// */ +// it("deletes data", async () => { +// solidLdoDataset.add( +// createQuad( +// namedNode("a"), +// namedNode("b"), +// namedNode("c"), +// namedNode(`${ROOT_COONTAINER}/test_leaf/sample.ttl`), +// ), +// ); +// solidLdoDataset.add( +// createQuad( +// namedNode(`${ROOT_COONTAINER}/test_leaf/`), +// namedNode("http://www.w3.org/ns/ldp#contains"), +// namedNode(`${ROOT_COONTAINER}/test_leaf/sample.ttl`), +// namedNode(`${ROOT_COONTAINER}/test_leaf/`), +// ), +// ); +// const leafRequester = new LeafRequester( +// `${ROOT_COONTAINER}/test_leaf/sample.ttl`, +// solidLdoDataset.context, +// ); +// const result = await leafRequester.delete(); +// expect(result.type).toBe("absent"); +// expect( +// solidLdoDataset.match( +// null, +// null, +// null, +// namedNode(`${ROOT_COONTAINER}/test_leaf/sample.ttl`), +// ).size, +// ).toBe(0); +// expect( +// solidLdoDataset.has( +// createQuad( +// namedNode(`${ROOT_COONTAINER}/test_leaf/`), +// namedNode("http://www.w3.org/ns/ldp#contains"), +// namedNode(`${ROOT_COONTAINER}/test_leaf/sample.ttl`), +// namedNode(`${ROOT_COONTAINER}/test_leaf/`), +// ), +// ), +// ).toBe(false); +// }); +// }); diff --git a/packages/connected-solid/test/RequestBatcher.test.ts b/packages/connected-solid/test/RequestBatcher.test.ts new file mode 100644 index 0000000..38ff5a2 --- /dev/null +++ b/packages/connected-solid/test/RequestBatcher.test.ts @@ -0,0 +1,112 @@ +import type { WaitingProcess } from "../src/util/RequestBatcher"; +import { RequestBatcher } from "../src/util/RequestBatcher"; + +describe("RequestBatcher", () => { + type ReadWaitingProcess = WaitingProcess<[string], string>; + + it("Batches a request", async () => { + const requestBatcher = new RequestBatcher({ batchMillis: 500 }); + const perform = async (input: string): Promise => { + await wait(100); + return `Hello ${input}`; + }; + const perform1 = jest.fn(perform); + const perform2 = jest.fn(perform); + const perform3 = jest.fn((input: string): Promise => { + expect(requestBatcher.isLoading("read")).toBe(true); + return perform(input); + }); + const perform4 = jest.fn(perform); + + const modifyQueue = (queue, currentlyProcessing, input: [string]) => { + const last = queue[queue.length - 1]; + if (last?.name === "read") { + (last as ReadWaitingProcess).args[0] += input; + return last; + } + return undefined; + }; + + let return1: string = ""; + let return2: string = ""; + let return3: string = ""; + let return4: string = ""; + + expect(requestBatcher.isLoading("read")).toBe(false); + + await Promise.all([ + requestBatcher + .queueProcess<[string], string>({ + name: "read", + args: ["a"], + perform: perform1, + modifyQueue, + }) + .then((val) => (return1 = val)), + requestBatcher + .queueProcess<[string], string>({ + name: "read", + args: ["b"], + perform: perform2, + modifyQueue, + }) + .then((val) => (return2 = val)), + , + requestBatcher + .queueProcess<[string], string>({ + name: "read", + args: ["c"], + perform: perform3, + modifyQueue, + }) + .then((val) => (return3 = val)), + , + requestBatcher + .queueProcess<[string], string>({ + name: "read", + args: ["d"], + perform: perform4, + modifyQueue, + }) + .then((val) => (return4 = val)), + , + ]); + + expect(return1).toBe("Hello a"); + expect(return2).toBe("Hello bcd"); + expect(return3).toBe("Hello bcd"); + expect(return4).toBe("Hello bcd"); + + expect(perform1).toHaveBeenCalledTimes(1); + expect(perform1).toHaveBeenCalledWith("a"); + expect(perform2).toHaveBeenCalledTimes(1); + expect(perform2).toHaveBeenCalledWith("bcd"); + expect(perform3).toHaveBeenCalledTimes(0); + expect(perform4).toHaveBeenCalledTimes(0); + }); + + it("sets a default batch millis", () => { + const requestBatcher = new RequestBatcher(); + expect(requestBatcher.batchMillis).toBe(1000); + }); + + it("handles an error being thrown in the process", () => { + const requestBatcher = new RequestBatcher({ batchMillis: 500 }); + const perform = async (_input: string): Promise => { + throw new Error("Test Error"); + }; + const perform1 = jest.fn(perform); + expect(() => + requestBatcher.queueProcess<[string], string>({ + name: "read", + args: ["a"], + perform: perform1, + modifyQueue: () => undefined, + }), + ).rejects.toThrowError("Test Error"); + }); +}); + +function wait(millis: number): Promise { + return new Promise((resolve) => setTimeout(resolve, millis)); +} diff --git a/packages/connected-solid/test/Websocket2023NotificationSubscription.test.ts b/packages/connected-solid/test/Websocket2023NotificationSubscription.test.ts new file mode 100644 index 0000000..1f97b0d --- /dev/null +++ b/packages/connected-solid/test/Websocket2023NotificationSubscription.test.ts @@ -0,0 +1,48 @@ +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 subscription = new Websocket2023NotificationSubscription( + new Leaf("https://example.com", { + fetch, + } as unknown as SolidLdoDatasetContext), + () => {}, + {} as unknown as SolidLdoDatasetContext, + () => WebSocketMock, + ); + + const subPromise = subscription.subscribeToWebsocket({ + receiveFrom: "http://example.com", + } as unknown as NotificationChannel); + WebSocketMock.onopen?.({} as Event); + + await subPromise; + + WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); + }); + + it("returns an error when websockets have an error at the beginning", async () => { + const WebSocketMock: WebSocket = {} as WebSocket; + + const subscription = new Websocket2023NotificationSubscription( + new Leaf("https://example.com", { + fetch, + } as unknown as SolidLdoDatasetContext), + () => {}, + {} 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); + await subPromise; + }); +}); diff --git a/packages/connected-solid/test/authFetch.helper.ts b/packages/connected-solid/test/authFetch.helper.ts new file mode 100644 index 0000000..fffee6a --- /dev/null +++ b/packages/connected-solid/test/authFetch.helper.ts @@ -0,0 +1,112 @@ +import type { KeyPair } from "@inrupt/solid-client-authn-core"; +import { + buildAuthenticatedFetch, + createDpopHeader, + generateDpopKeyPair, +} from "@inrupt/solid-client-authn-core"; +import fetch from "cross-fetch"; + +const config = { + podName: process.env.USER_NAME || "example", + email: process.env.EMAIL || "hello@example.com", + password: process.env.PASSWORD || "abc123", +}; + +async function getAuthorization(): Promise { + // First we request the account API controls to find out where we can log in + const indexResponse = await fetch("http://localhost:3001/.account/"); + const { controls } = await indexResponse.json(); + + // And then we log in to the account API + const response = await fetch(controls.password.login, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: config.email, + password: config.password, + }), + }); + // This authorization value will be used to authenticate in the next step + const result = await response.json(); + return result.authorization; +} + +async function getSecret( + authorization: string, +): Promise<{ id: string; secret: string; resource: string }> { + // Now that we are logged in, we need to request the updated controls from the server. + // These will now have more values than in the previous example. + const indexResponse = await fetch("http://localhost:3001/.account/", { + headers: { authorization: `CSS-Account-Token ${authorization}` }, + }); + const { controls } = await indexResponse.json(); + + // Here we request the server to generate a token on our account + const response = await fetch(controls.account.clientCredentials, { + method: "POST", + headers: { + authorization: `CSS-Account-Token ${authorization}`, + "content-type": "application/json", + }, + // The name field will be used when generating the ID of your token. + // The WebID field determines which WebID you will identify as when using the token. + // Only WebIDs linked to your account can be used. + body: JSON.stringify({ + name: "my-token", + webId: `http://localhost:3001/${config.podName}/profile/card#me`, + }), + }); + + // These are the identifier and secret of your token. + // Store the secret somewhere safe as there is no way to request it again from the server! + // The `resource` value can be used to delete the token at a later point in time. + const response2 = await response.json(); + return response2; +} + +async function getAccessToken( + id: string, + secret: string, +): Promise<{ accessToken: string; dpopKey: KeyPair }> { + try { + // A key pair is needed for encryption. + // This function from `solid-client-authn` generates such a pair for you. + const dpopKey = await generateDpopKeyPair(); + + // These are the ID and secret generated in the previous step. + // Both the ID and the secret need to be form-encoded. + const authString = `${encodeURIComponent(id)}:${encodeURIComponent( + secret, + )}`; + // This URL can be found by looking at the "token_endpoint" field at + // http://localhost:3001/.well-known/openid-configuration + // if your server is hosted at http://localhost:3000/. + const tokenUrl = "http://localhost:3001/.oidc/token"; + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + // The header needs to be in base64 encoding. + authorization: `Basic ${Buffer.from(authString).toString("base64")}`, + "content-type": "application/x-www-form-urlencoded", + dpop: await createDpopHeader(tokenUrl, "POST", dpopKey), + }, + body: "grant_type=client_credentials&scope=webid", + }); + + // This is the Access token that will be used to do an authenticated request to the server. + // The JSON also contains an "expires_in" field in seconds, + // which you can use to know when you need request a new Access token. + const response2 = await response.json(); + return { accessToken: response2.access_token, dpopKey }; + } catch (err) { + console.error(err); + throw err; + } +} + +export async function generateAuthFetch() { + const authorization = await getAuthorization(); + const { id, secret } = await getSecret(authorization); + const { accessToken, dpopKey } = await getAccessToken(id, secret); + return await buildAuthenticatedFetch(accessToken, { dpopKey }); +} diff --git a/packages/connected-solid/test/configs/server-config-without-websocket.json b/packages/connected-solid/test/configs/server-config-without-websocket.json new file mode 100644 index 0000000..626d082 --- /dev/null +++ b/packages/connected-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/connected-solid/test/configs/server-config.json b/packages/connected-solid/test/configs/server-config.json new file mode 100644 index 0000000..5e96784 --- /dev/null +++ b/packages/connected-solid/test/configs/server-config.json @@ -0,0 +1,43 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld" + ], + "import": [ + "css:config/app/init/static-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/all.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/default.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/memory.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/pod.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/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A Solid server that stores its resources on disk and uses WAC for authorization." + } + ] +} \ No newline at end of file diff --git a/packages/connected-solid/test/configs/solid-css-seed.json b/packages/connected-solid/test/configs/solid-css-seed.json new file mode 100644 index 0000000..5894d0d --- /dev/null +++ b/packages/connected-solid/test/configs/solid-css-seed.json @@ -0,0 +1,9 @@ +[ + { + "email": "hello@example.com", + "password": "abc123", + "pods": [ + { "name": "example" } + ] + } +] \ No newline at end of file diff --git a/packages/connected-solid/test/guaranteeFetch.test.ts b/packages/connected-solid/test/guaranteeFetch.test.ts new file mode 100644 index 0000000..92cf6c4 --- /dev/null +++ b/packages/connected-solid/test/guaranteeFetch.test.ts @@ -0,0 +1,8 @@ +import { guaranteeFetch } from "../src/util/guaranteeFetch"; +import crossFetch from "cross-fetch"; + +describe("guaranteeFetch", () => { + it("returns crossfetch when no fetch is provided", () => { + expect(guaranteeFetch()).toBe(crossFetch); + }); +}); diff --git a/packages/connected-solid/test/setup-tests.ts b/packages/connected-solid/test/setup-tests.ts new file mode 100644 index 0000000..f4378ae --- /dev/null +++ b/packages/connected-solid/test/setup-tests.ts @@ -0,0 +1,3 @@ +import { config } from "dotenv"; + +config(); diff --git a/packages/connected-solid/test/solidServer.helper.ts b/packages/connected-solid/test/solidServer.helper.ts new file mode 100644 index 0000000..38069d5 --- /dev/null +++ b/packages/connected-solid/test/solidServer.helper.ts @@ -0,0 +1,40 @@ +// Taken from https://github.com/comunica/comunica/blob/b237be4265c353a62a876187d9e21e3bc05123a3/engines/query-sparql/test/QuerySparql-solid-test.ts#L9 + +import * as path from "path"; +import type { App } from "@solid/community-server"; +import { AppRunner, resolveModulePath } from "@solid/community-server"; +import "jest-rdf"; + +export const SERVER_DOMAIN = process.env.SERVER || "http://localhost:3001/"; +export const ROOT_ROUTE = process.env.ROOT_CONTAINER || ""; +export const ROOT_CONTAINER = `${SERVER_DOMAIN}${ROOT_ROUTE}`; +export const WEB_ID = + process.env.WEB_ID || `${SERVER_DOMAIN}example/profile/card#me`; + +// Use an increased timeout, since the CSS server takes too much setup time. +jest.setTimeout(40_000); + +export async function createApp(customConfigPath?: string): Promise { + if (process.env.SERVER) { + return { + start: () => {}, + stop: () => {}, + } as App; + } + const appRunner = new AppRunner(); + + return appRunner.create({ + loaderProperties: { + mainModulePath: resolveModulePath(""), + typeChecking: false, + }, + config: customConfigPath ?? resolveModulePath("config/file-root.json"), + variableBindings: {}, + shorthand: { + port: 3_001, + loggingLevel: "off", + seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"), + rootFilePath: path.join(__dirname, "./data"), + }, + }); +} diff --git a/packages/connected-solid/test/uriTypes.test.ts b/packages/connected-solid/test/uriTypes.test.ts new file mode 100644 index 0000000..589e874 --- /dev/null +++ b/packages/connected-solid/test/uriTypes.test.ts @@ -0,0 +1,7 @@ +import { isLeafUri } from "../src"; + +describe("isLeafUri", () => { + it("returns true if the given value is a leaf URI", () => { + expect(isLeafUri("https://example.com/index.ttl")).toBe(true); + }); +}); diff --git a/packages/connected-solid/test/utils.helper.ts b/packages/connected-solid/test/utils.helper.ts new file mode 100644 index 0000000..109fa51 --- /dev/null +++ b/packages/connected-solid/test/utils.helper.ts @@ -0,0 +1,3 @@ +export async function wait(millis: number) { + return new Promise((resolve) => setTimeout(resolve, millis)); +} diff --git a/packages/connected/jest.config.js b/packages/connected/jest.config.js index c55a5f7..9bfe763 100644 --- a/packages/connected/jest.config.js +++ b/packages/connected/jest.config.js @@ -3,7 +3,6 @@ const sharedConfig = require("../../jest.config.js"); module.exports = { ...sharedConfig, rootDir: "./", - setupFiles: ["/test/setup-tests.ts"], transform: { "^.+\\.(ts|tsx)?$": "ts-jest", "^.+\\.(js|jsx)$": "babel-jest", diff --git a/packages/connected/src/test.ts b/packages/connected/src/test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/connected/test/ErrorResult.test.ts b/packages/connected/test/ErrorResult.test.ts new file mode 100644 index 0000000..273f541 --- /dev/null +++ b/packages/connected/test/ErrorResult.test.ts @@ -0,0 +1,66 @@ +import { + AggregateError, + ErrorResult, + ResourceError, + UnexpectedResourceError, +} from "../src/results/error/ErrorResult"; +import { InvalidUriError } from "../src/results/error/InvalidUriError"; +import { MockResouce } from "./MockResource"; + +const mockResource = new MockResouce("https://example.com/"); + +describe("ErrorResult", () => { + describe("fromThrown", () => { + it("returns an UnexpecteResourceError if a string is provided", () => { + expect( + UnexpectedResourceError.fromThrown(mockResource, "hello").message, + ).toBe("hello"); + }); + + it("returns an UnexpecteResourceError if an odd valud is provided", () => { + expect(UnexpectedResourceError.fromThrown(mockResource, 5).message).toBe( + "Error of type number thrown: 5", + ); + }); + }); + + describe("AggregateError", () => { + it("flattens aggregate errors provided to the constructor", () => { + const err1 = UnexpectedResourceError.fromThrown(mockResource, "1"); + const err2 = UnexpectedResourceError.fromThrown(mockResource, "2"); + const err3 = UnexpectedResourceError.fromThrown(mockResource, "3"); + const err4 = UnexpectedResourceError.fromThrown(mockResource, "4"); + const aggErr1 = new AggregateError([err1, err2]); + const aggErr2 = new AggregateError([err3, err4]); + const finalAgg = new AggregateError([aggErr1, aggErr2]); + expect(finalAgg.errors.length).toBe(4); + }); + }); + + describe("default messages", () => { + class ConcreteResourceError extends ResourceError { + readonly type = "concreteResourceError" as const; + } + class ConcreteErrorResult extends ErrorResult { + readonly type = "concreteErrorResult" as const; + } + + it("ResourceError fallsback to a default message if none is provided", () => { + expect(new ConcreteResourceError(mockResource).message).toBe( + "An unkown error for https://example.com/", + ); + }); + + it("ErrorResult fallsback to a default message if none is provided", () => { + expect(new ConcreteErrorResult().message).toBe( + "An unkown error was encountered.", + ); + }); + + it("InvalidUriError fallsback to a default message if none is provided", () => { + expect(new InvalidUriError(mockResource).message).toBe( + "https://example.com/ is an invalid uri.", + ); + }); + }); +}); diff --git a/packages/connected/test/MockResource.ts b/packages/connected/test/MockResource.ts new file mode 100644 index 0000000..3261ee3 --- /dev/null +++ b/packages/connected/test/MockResource.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import EventEmitter from "events"; +import { + Unfetched, + type ConnectedResult, + type Resource, + type ResourceEventEmitter, + type ResourceResult, +} from "../src"; + +export class MockResouce + extends (EventEmitter as new () => ResourceEventEmitter) + implements Resource +{ + isError = false as const; + uri: string; + type = "mock" as const; + status: ConnectedResult; + + constructor(uri: string) { + super(); + this.uri = uri; + this.status = new Unfetched(this); + } + + isLoading(): boolean { + throw new Error("Method not implemented."); + } + isFetched(): boolean { + throw new Error("Method not implemented."); + } + isUnfetched(): boolean { + throw new Error("Method not implemented."); + } + isDoingInitialFetch(): boolean { + throw new Error("Method not implemented."); + } + isPresent(): boolean | undefined { + throw new Error("Method not implemented."); + } + isAbsent(): boolean | undefined { + throw new Error("Method not implemented."); + } + isSubscribedToNotifications(): boolean { + throw new Error("Method not implemented."); + } + read(): Promise> { + throw new Error("Method not implemented."); + } + readIfAbsent(): Promise> { + throw new Error("Method not implemented."); + } + subscribeToNotifications(_callbacks?: { + onNotification: (message: any) => void; + onNotificationError: (err: Error) => void; + }): Promise { + throw new Error("Method not implemented."); + } + unsubscribeFromNotifications(_subscriptionId: string): Promise { + throw new Error("Method not implemented."); + } + unsubscribeFromAllNotifications(): Promise { + throw new Error("Method not implemented."); + } +}