// Copyright Inrupt Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the // Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import { describe, expect, it, jest } from "@jest/globals"; import type * as RDF from "@rdfjs/types"; import { DataFactory } from "n3"; import { ldp, rdf } from "../constants"; import type { IriString, LocalNode, SolidDataset, UrlString, WithChangeLog, WithResourceInfo, } from "../interfaces"; import { getLocalNodeIri } from "../rdf.internal"; import { mockResponse } from "../tests.internal"; import { addInteger, addStringNoLocale, addUrl } from "../thing/add"; import { getUrl } from "../thing/get"; import { mockThingFrom } from "../thing/mock"; import { removeStringNoLocale } from "../thing/remove"; import { asIri, createThing, getThing, getThingAll, setThing, } from "../thing/thing"; import { mockContainerFrom, mockSolidDatasetFrom } from "./mock"; import type { Parser } from "./solidDataset"; import { changeLogAsMarkdown, createContainerAt, createContainerInContainer, createSolidDataset, deleteContainer, deleteSolidDataset, getContainedResourceUrlAll, getSolidDataset, getWellKnownSolid, responseToSolidDataset, saveSolidDatasetAt, saveSolidDatasetInContainer, solidDatasetAsMarkdown, validateContainedResourceAll, } from "./solidDataset"; const fetchMockImplementation: typeof fetch = async () => new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }); jest.spyOn(globalThis, "fetch").mockImplementation(fetchMockImplementation); const bnode = DataFactory.blankNode("b0"); const data: RDF.Quad[] = [ DataFactory.quad( bnode, DataFactory.namedNode("http://inrupt.com/ns/ess#consentIssuer"), DataFactory.namedNode("https://consent.pod.inrupt.com"), ), DataFactory.quad( bnode, DataFactory.namedNode( "http://inrupt.com/ns/ess#notificationGatewayEndpoint", ), DataFactory.namedNode("https://notification.pod.inrupt.com"), ), DataFactory.quad( bnode, DataFactory.namedNode("http://inrupt.com/ns/ess#powerSwitchEndpoint"), DataFactory.namedNode("https://pod.inrupt.com/powerswitch/username"), ), DataFactory.quad( bnode, DataFactory.namedNode("http://www.w3.org/ns/pim/space#storage"), DataFactory.namedNode("https://pod.inrupt.com/username/"), ), ]; jest.mock("../formats/jsonLd", () => ({ getJsonLdParser: jest.fn().mockImplementation((): Parser => { const onQuadCallbacks: Array[0]> = []; const onCompleteCallbacks: Array[0]> = []; const onErrorCallbacks: Array[0]> = []; return { onQuad: (callback) => { onQuadCallbacks.push(callback); }, onError: (callback) => { onErrorCallbacks.push(callback); }, onComplete: (callback) => { onCompleteCallbacks.push(callback); }, parse: async () => { for (const quad of data) { onQuadCallbacks.forEach((callback) => callback(quad)); } onCompleteCallbacks.forEach((callback) => callback()); }, }; }), })); describe("createSolidDataset", () => { it("should initialise a new empty SolidDataset", () => { const solidDataset = createSolidDataset(); expect(solidDataset.graphs.default).toStrictEqual({}); }); }); describe("responseToSolidDataset", () => { it("returns a SolidDataset representing the fetched Turtle", async () => { const turtle = ` @base . @prefix : <#>. @prefix foaf: . @prefix vcard: . <> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. :me a foaf:Person; vcard:fn "Vincent", [:predicate ]. `; const response = mockResponse( turtle, { headers: { "Content-Type": "text/turtle", }, }, "https://some.pod/resource", ); const solidDataset = await responseToSolidDataset(response); expect(solidDataset).toEqual( expect.objectContaining({ graphs: { default: { // The blank node identifier is by definition unstable. // If this test starts failing, it may be due to the // identifier changing, which is not forbidden. "_:n3-0": { type: "Subject", url: "_:n3-0", predicates: { "https://some.pod/resource#predicate": { namedNodes: ["for://a.blank/node"], }, }, }, "https://some.pod/resource": { predicates: { "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { namedNodes: [ "http://xmlns.com/foaf/0.1/PersonalProfileDocument", ], }, "http://xmlns.com/foaf/0.1/maker": { namedNodes: ["https://some.pod/resource#me"], }, "http://xmlns.com/foaf/0.1/primaryTopic": { namedNodes: ["https://some.pod/resource#me"], }, }, type: "Subject", url: "https://some.pod/resource", }, "https://some.pod/resource#me": { predicates: { "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { namedNodes: ["http://xmlns.com/foaf/0.1/Person"], }, "http://www.w3.org/2006/vcard/ns#fn": { // Here, the blank node identifier isn't referenced explicitly for resiliency. blankNodes: [expect.stringMatching(/^_:/)], literals: { "http://www.w3.org/2001/XMLSchema#string": ["Vincent"], }, }, }, type: "Subject", url: "https://some.pod/resource#me", }, }, }, internal_resourceInfo: { contentType: "text/turtle", isRawData: false, linkedResources: {}, sourceIri: "https://some.pod/resource", }, type: "Dataset", }), ); }); it("throws a meaningful error when the server returned a 403", async () => { const response = new Response("Not allowed", { status: 403, statusText: "Forbidden", }); jest .spyOn(response, "url", "get") .mockReturnValue("https://some.pod/resource"); const parsePromise = responseToSolidDataset(response); await expect(parsePromise).rejects.toThrow( /Fetching the SolidDataset at \[https:\/\/some.pod\/resource\] failed: \[403\] \[Forbidden\]/, ); }); it("can match MIME types even if the Content-Type header also specifies a character encoding", async () => { const response = new Response("", { headers: { "Content-Type": "text/turtle;charset=UTF-8", }, }); jest .spyOn(response, "url", "get") .mockReturnValue("https://some.pod/resource"); const parsePromise = responseToSolidDataset(response); await expect(parsePromise).resolves.not.toThrow(); }); it("throws an error when no parsers for the Resource's content type are available", async () => { const response = new Response("", { headers: { "Content-Type": "some unsupported content type", }, }); jest .spyOn(response, "url", "get") .mockReturnValue("https://some.pod/resource"); const parsePromise = responseToSolidDataset(response); await expect(parsePromise).rejects.toThrow( new Error( "The Resource at [https://some.pod/resource] has a MIME type of [some unsupported content type], but the only parsers available are for the following MIME types: [text/turtle].", ), ); }); it("throws an error when the Parser cannot parse the data", async () => { const response = new Response("", { headers: { "Content-Type": "text/turtle" }, }); jest .spyOn(response, "url", "get") .mockReturnValue("https://some.pod/resource"); let resolveDataPromise: (value: string) => void = jest.fn(); const dataPromise = new Promise((resolve) => { resolveDataPromise = resolve; }); jest.spyOn(response, "text").mockReturnValue(dataPromise); const onErrorHandlers: Array[0]> = []; const mockParser: Parser = { onComplete: jest.fn(), onQuad: jest.fn(), parse: jest.fn(), onError: (errorHandler) => onErrorHandlers.push(errorHandler), }; const parsePromise = responseToSolidDataset(response, { parsers: { "text/turtle": mockParser }, }); resolveDataPromise(""); await dataPromise; onErrorHandlers[0](new Error("Some error")); await expect(parsePromise).rejects.toThrow( new Error( "Encountered an error parsing the Resource at [https://some.pod/resource] with content type [text/turtle]: Error: Some error", ), ); }); }); describe("getSolidDataset", () => { it("calls the included fetcher by default", async () => { jest .spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(undefined, { headers: { "Content-Type": "text/turtle" } }), ); await getSolidDataset("https://some.pod/resource"); expect(fetch).toHaveBeenCalledWith("https://some.pod/resource", { headers: { Accept: "text/turtle" }, }); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "Content-Type": "text/turtle" }, }), ); await getSolidDataset("https://some.pod/resource", { fetch: mockFetch }); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); }); describe("normalizing the target URL", () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "Content-Type": "text/turtle" }, }), ); it("removes double slashes from path", async () => { await getSolidDataset("https://some.pod//resource//path", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource/path"); }); it("doesn't change the provided URL trailing slash", async () => { await getSolidDataset("https://some.pod/container/", { fetch: mockFetch, }); await getSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource"); }); it("removes relative path components", async () => { await getSolidDataset("https://some.pod/././test/../resource", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); }); }); it("adds an Accept header accepting turtle by default", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "Content-Type": "text/turtle" }, }), ); await getSolidDataset("https://some.pod/resource", { fetch: mockFetch }); expect(mockFetch.mock.calls[0][1]).toEqual({ headers: { Accept: "text/turtle", }, }); }); it("advertises all formats supported by given parsers in the Accept header", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "Content-Type": "text/turtle" }, }), ); const mockParser: Parser = { onComplete: jest .fn() .mockImplementationOnce((completionCallback: any) => completionCallback(), ), onQuad: jest.fn(), parse: jest.fn(), onError: jest.fn(), }; const mockParsers = { "text/turtle": mockParser, "application/n-triples": mockParser, }; await getSolidDataset("https://some.pod/resource", { fetch: mockFetch, parsers: mockParsers, }); expect(mockFetch.mock.calls[0][1]).toEqual({ headers: { Accept: "text/turtle, application/n-triples", }, }); }); it("can be called with NamedNodes", async () => { const mockFetch = jest.fn().mockResolvedValueOnce( new Response(undefined, { headers: { "Content-Type": "text/turtle" }, }), ); await getSolidDataset(DataFactory.namedNode("https://some.pod/resource"), { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); }); it("keeps track of where the SolidDataset was fetched from", async () => { const mockFetch = jest .fn() .mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle" } }, "https://some.pod/resource", ), ); const solidDataset = await getSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); expect(solidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); }); it("provides the IRI of the relevant ACL resource, if provided", async () => { const mockFetch = jest .fn() .mockResolvedValueOnce( mockResponse( undefined, { headers: { Link: '; rel="acl"', "Content-Type": "text/turtle", }, }, "https://some.pod/container/", ), ) .mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle" }, }, "https://some.pod/container/aclresource.acl", ), ); const solidDataset = await getSolidDataset( "https://some.pod/container/resource", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.aclUrl).toBe( "https://some.pod/container/aclresource.acl", ); }); it("does not provide an IRI to an ACL resource if not provided one by the server", async () => { const mockFetch = jest.fn().mockResolvedValue( mockResponse( undefined, { headers: { Link: '; rel="not-acl"', "Content-Type": "text/turtle", }, }, "https://arbitrary.pod", ), ); const solidDataset = await getSolidDataset( "https://some.pod/container/resource", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.aclUrl).toBeUndefined(); }); it("provides the relevant access permissions to the Resource, if available", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "wac-aLLOW": 'public="read",user="read write append control"', "Content-Type": "text/turtle", }, }), ); const solidDataset = await getSolidDataset( "https://arbitrary.pod/container/resource", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.permissions).toEqual({ user: { read: true, append: true, write: true, control: true, }, public: { read: true, append: false, write: false, control: false, }, }); }); it("defaults permissions to false if they are not set, or are set with invalid syntax", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { // Public permissions are missing double quotes, user permissions are absent: "WAC-Allow": "public=read", "Content-Type": "text/turtle", }, }), ); const solidDataset = await getSolidDataset( "https://arbitrary.pod/container/resource", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.permissions).toEqual({ user: { read: false, append: false, write: false, control: false, }, public: { read: false, append: false, write: false, control: false, }, }); }); it("does not provide the resource's access permissions if not provided by the server", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "Content-Type": "text/turtle", }, }), ); const solidDataset = await getSolidDataset( "https://arbitrary.pod/container/resource", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.permissions).toBeUndefined(); }); it("returns a SolidDataset representing the fetched Turtle", async () => { const turtle = ` @prefix : <#>. @prefix profile: <./>. @prefix foaf: . @prefix vcard: . <> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. :me a foaf:Person; vcard:fn "Vincent". `; const mockFetch = jest .fn() .mockResolvedValue( mockResponse( turtle, { headers: { "Content-Type": "text/turtle" } }, "https://arbitrary.pod/resource", ), ); const solidDataset = await getSolidDataset( "https://arbitrary.pod/resource", { fetch: mockFetch, }, ); expect(solidDataset).toMatchSnapshot(); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response("Not allowed", { status: 403, statusText: "Forbidden" }), ); const fetchPromise = getSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the Resource at \[https:\/\/some.pod\/resource\] failed: \[403\] \[Forbidden\]/, ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response("Not found", { status: 404, statusText: "Not Found" }), ); const fetchPromise = getSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the Resource at \[https:\/\/some.pod\/resource\] failed: \[404\] \[Not Found\]/, ); }); it("includes the status code, status message and response body when a request failed", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("Teapots don't make coffee", { status: 418, statusText: "I'm a teapot!", }), ); const fetchPromise = getSolidDataset("https://arbitrary.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringMatching("Teapots don't make coffee"), }); }); }); describe("saveSolidDatasetAt", () => { it("calls the included fetcher by default", async () => { jest.spyOn(globalThis, "fetch").mockResolvedValue( mockResponse( null, { headers: { Location: "/resource" }, }, "https://some.pod/resource", ), ); await saveSolidDatasetAt("https://some.pod/resource", createSolidDataset()); expect(fetch).toHaveBeenCalledTimes(1); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); await saveSolidDatasetAt( "https://some.pod/resource", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); }); describe("normalizing the target URL", () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); it("removes double slashes from path", async () => { await saveSolidDatasetAt( "https://some.pod//resource//path", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource/path"); }); it("leaves input trailing slashes unchanged", async () => { await saveSolidDatasetAt( "https://some.pod/resource", createSolidDataset(), { fetch: mockFetch, }, ); await saveSolidDatasetAt( "https://some.pod/container/", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/container/"); }); it("removes relative path components", async () => { await saveSolidDatasetAt( "https://some.pod/././test/../resource", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); }); }); describe("when saving a new resource", () => { it("sends the given SolidDataset to the Pod", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); const mockDataset = setThing(createSolidDataset(), mockThing); await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[0][1]?.method).toBe("PUT"); expect( (mockFetch.mock.calls[0][1]?.headers as Record)[ "Content-Type" ], ).toBe("text/turtle"); expect( (mockFetch.mock.calls[0][1]?.headers as Record).Link, ).toBe('; rel="type"'); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( " .", ); }); it("uses the response URL to compute the saved resource source IRI", async () => { const mockFetch = jest .fn() .mockResolvedValue(mockResponse(null, {}, "https://saved.at/url")); const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); const mockDataset = setThing(createSolidDataset(), mockThing); const savedDataset = await saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); expect(savedDataset.internal_resourceInfo.sourceIri).toBe( "https://saved.at/url", ); }); it("sets relative IRIs for LocalNodes", async () => { const mockFetch = jest .fn() .mockResolvedValue( mockResponse(null, {}, "https://some.irrelevant/url"), ); const mockObjectThing = createThing({ name: "some-object-name" }); const mockThing = addUrl( createThing({ name: "some-subject-name" }), "https://arbitrary.vocab/predicate", mockObjectThing, ); const mockDataset = setThing(createSolidDataset(), mockThing); await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][1]?.body).toMatch("#some-subject-name"); expect(mockFetch.mock.calls[0][1]?.body).toMatch("#some-object-name"); }); it("resolves relative IRIs in the returned SolidDataset", async () => { const mockFetch = jest .fn() .mockResolvedValue(mockResponse(null, {}, "https://saved.at/url")); const mockObjectThing = createThing({ name: "some-object-name" }); let mockThing = addUrl( createThing({ name: "some-subject-name" }), "https://arbitrary.vocab/predicate", mockObjectThing, ); mockThing = addUrl( mockThing, "https://arbitrary.vocab/predicate", "https://regular.url", ); mockThing = addUrl( mockThing, "https://arbitrary-other.vocab/predicate", "https://regular.url", ); mockThing = addInteger(mockThing, "https://another.vocab/predicate", 42); const mockDataset = setThing(createSolidDataset(), mockThing); const storedSolidDataset = await saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); const storedThing = getThing( storedSolidDataset, "https://saved.at/url#some-subject-name", ); expect(storedThing).not.toBeNull(); expect(getUrl(storedThing!, "https://arbitrary.vocab/predicate")).toBe( "https://saved.at/url#some-object-name", ); }); it("also resolves relative IRIs for Things that have absolute IRIs", async () => { const mockFetch = jest .fn() .mockResolvedValue(mockResponse(null, {}, "https://saved.at/url")); const mockObjectThing = createThing({ name: "some-object-name" }); let mockThing = addUrl( createThing({ url: "https://some.pod/resource#thing" }), "https://arbitrary.vocab/predicate", mockObjectThing, ); mockThing = addUrl( mockThing, "https://arbitrary.vocab/predicate", "https://regular.url", ); mockThing = addUrl( mockThing, "https://arbitrary-other.vocab/predicate", "https://regular.url", ); mockThing = addInteger(mockThing, "https://another.vocab/predicate", 42); const mockDataset = setThing(createSolidDataset(), mockThing); const storedSolidDataset = await saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); const storedThing = getThing( storedSolidDataset, "https://some.pod/resource#thing", ); expect(storedThing).not.toBeNull(); expect(getUrl(storedThing!, "https://arbitrary.vocab/predicate")).toBe( "https://saved.at/url#some-object-name", ); }); it("makes sure the returned SolidDataset has an empty change log", async () => { const mockDataset = createSolidDataset(); const storedSolidDataset = await saveSolidDatasetAt( "https://arbitrary.pod/resource", mockDataset, ); expect(storedSolidDataset.internal_changeLog).toEqual({ additions: [], deletions: [], }); }); it("tells the Pod to only save new data when no data exists yet", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); await saveSolidDatasetAt( "https://arbitrary.pod/resource", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls[0][1]?.headers).toMatchObject({ "If-None-Match": "*", }); }); it("uses the provided prefixes if any", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); const mockDataset = setThing(createSolidDataset(), mockThing); await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, prefixes: { ex: "https://arbitrary.vocab/" }, }); expect(mockFetch.mock.calls).toHaveLength(1); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toContain( "ex:subject ex:predicate ex:object.", ); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("Not allowed", { status: 403, statusText: "Forbidden", }), ); const fetchPromise = saveSolidDatasetAt( "https://some.pod/resource", createSolidDataset(), { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toThrow( /Storing the Resource at \[https:\/\/some.pod\/resource\] failed: \[403\] \[Forbidden\].*[\s\S]*The SolidDataset that was sent to the Pod is listed below/m, ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response("Not found", { status: 404, statusText: "Not Found" }), ); const fetchPromise = saveSolidDatasetAt( "https://some.pod/resource", createSolidDataset(), { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toThrow( /Storing the Resource at \[https:\/\/some.pod\/resource\] failed: \[404\] \[Not Found\].*[\s\S]*The SolidDataset that was sent to the Pod is listed below/m, ); }); it("includes the status code, status message and error body when a request failed", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("Teapots don't make coffee.", { status: 418, statusText: "I'm a teapot!", }), ); const fetchPromise = saveSolidDatasetAt( "https://arbitrary.pod/resource", createSolidDataset(), { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringMatching("Teapots don't make coffee"), }); }); it("tries to create the given SolidDataset on the Pod, even if it has an empty changelog", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const mockDataset = { ...createSolidDataset(), internal_changeLog: { additions: [], deletions: [] }, }; await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][1]?.method).toBe("PUT"); }); }); describe("when updating an existing resource", () => { function getMockUpdatedDataset( changeLog: WithChangeLog["internal_changeLog"], fromUrl: IriString, ): SolidDataset & WithChangeLog & WithResourceInfo { const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); let mockDataset = setThing(createSolidDataset(), mockThing); changeLog.additions.forEach((tripleToAdd) => { let additionThing = getThing(mockDataset, tripleToAdd.subject.value) ?? createThing({ url: tripleToAdd.subject.value }); additionThing = addUrl( additionThing, tripleToAdd.predicate.value, tripleToAdd.object.value, ); mockDataset = setThing(mockDataset, additionThing); }); const resourceInfo: WithResourceInfo["internal_resourceInfo"] = { sourceIri: fromUrl, isRawData: false, }; return { ...mockDataset, internal_changeLog: changeLog, internal_resourceInfo: resourceInfo, }; } it("sends just the change log to the Pod", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://some.vocab/subject"), DataFactory.namedNode("https://some.vocab/predicate"), DataFactory.namedNode("https://some.vocab/object"), undefined, ), ], deletions: [ DataFactory.quad( DataFactory.namedNode("https://some-other.vocab/subject"), DataFactory.namedNode("https://some-other.vocab/predicate"), DataFactory.namedNode("https://some-other.vocab/object"), undefined, ), ], }, "https://some.pod/resource", ); await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[0][1]?.method).toBe("PATCH"); expect( (mockFetch.mock.calls[0][1]?.headers as Record)[ "Content-Type" ], ).toBe("application/sparql-update"); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( "DELETE DATA { .}; " + "INSERT DATA { .};", ); }); it("uses the response IRI to compute the saved resource IRI", async () => { const mockFetch = jest .fn() .mockResolvedValue(mockResponse(null, {}, "https://saved.at/url")); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://some.vocab/subject"), DataFactory.namedNode("https://some.vocab/predicate"), DataFactory.namedNode("https://some.vocab/object"), undefined, ), ], deletions: [], }, "https://some.pod/resource", ); const savedDataset = await saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); expect(savedDataset.internal_resourceInfo.sourceIri).toBe( "https://saved.at/url", ); }); it("sets relative IRIs for LocalNodes", async () => { const mockFetch = jest .fn() .mockResolvedValue( mockResponse(null, {}, "https://some.irrelevant/url"), ); const subjectLocal: LocalNode = DataFactory.namedNode( getLocalNodeIri("some-subject-name"), ); const objectLocal: LocalNode = DataFactory.namedNode( getLocalNodeIri("some-object-name"), ); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( subjectLocal, DataFactory.namedNode("https://some.vocab/predicate"), objectLocal, undefined, ), ], deletions: [ DataFactory.quad( subjectLocal, DataFactory.namedNode("https://some-other.vocab/predicate"), objectLocal, undefined, ), ], }, "https://some.pod/resource", ); await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, }); const [deleteStatement, insertStatement] = ( mockFetch.mock.calls[0][1]!.body as string ).split(";"); expect(deleteStatement).toMatch("#some-subject-name"); expect(insertStatement).toMatch("#some-subject-name"); expect(deleteStatement).toMatch("#some-object-name"); expect(insertStatement).toMatch("#some-object-name"); }); it("resolves relative IRIs in the returned SolidDataset", async () => { const mockFetch = jest .fn() .mockResolvedValue(mockResponse(null, {}, "https://saved.at/url")); const subjectLocal: LocalNode = DataFactory.namedNode( getLocalNodeIri("some-subject-name"), ); const objectLocal: LocalNode = DataFactory.namedNode( getLocalNodeIri("some-object-name"), ); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( subjectLocal, DataFactory.namedNode("https://some.vocab/predicate"), objectLocal, undefined, ), ], deletions: [], }, "https://some.pod/resource", ); const storedSolidDataset = await saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); const storedThing = getThing( storedSolidDataset, "https://saved.at/url#some-subject-name", ); expect(storedThing).not.toBeNull(); expect(getUrl(storedThing!, "https://some.vocab/predicate")).toBe( "https://saved.at/url#some-object-name", ); }); it("sends the full SolidDataset if it is saved to a different IRI", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const mockDataset = getMockUpdatedDataset( { additions: [], deletions: [] }, "https://some.pod/resource", ); await saveSolidDatasetAt("https://some-other.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( "https://some-other.pod/resource", ); expect(mockFetch.mock.calls[0][1]?.method).toBe("PUT"); // Even though the change log is empty there should still be a body, // since the Dataset itself is not empty: expect( (mockFetch.mock.calls[0][1]?.body as string).trim().length, ).toBeGreaterThan(0); }); it("ignores hash fragments in the target IRI if any when determining if the request is an update or a creation", async () => { const mockedResponse = new Response(); jest .spyOn(mockedResponse, "url", "get") .mockReturnValue("https://some.url"); const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(mockedResponse)); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://some.vocab/subject"), DataFactory.namedNode("https://some.vocab/predicate"), DataFactory.namedNode("https://some.vocab/object"), undefined, ), ], deletions: [ DataFactory.quad( DataFactory.namedNode("https://some-other.vocab/subject"), DataFactory.namedNode("https://some-other.vocab/predicate"), DataFactory.namedNode("https://some-other.vocab/object"), undefined, ), ], }, "https://some.pod/resource", ); await saveSolidDatasetAt( "https://some.pod/resource#something", mockDataset, { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( // Note that the hash fragment is still present in the target URL "https://some.pod/resource#something", ); // The library detects that the desired operation is a PATCH, and not a PUT expect(mockFetch.mock.calls[0][1]?.method).toBe("PATCH"); }); it("does not include a DELETE statement if the change log contains no deletions", async () => { const mockFetch = jest .fn() .mockResolvedValue( mockResponse(null, {}, "https://some.irrelevant/url"), ); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://arbitrary.vocab/subject"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.namedNode("https://arbitrary.vocab/object"), undefined, ), ], deletions: [], }, "https://arbitrary.pod/resource", ); await saveSolidDatasetAt("https://arbitrary.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][1]?.body as string).not.toMatch("DELETE"); }); it("does not include an INSERT statement if the change log contains no additions", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const mockDataset = getMockUpdatedDataset( { additions: [], deletions: [ DataFactory.quad( DataFactory.namedNode("https://arbitrary.vocab/subject"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.namedNode("https://arbitrary.vocab/object"), undefined, ), ], }, "https://arbitrary.pod/resource", ); await saveSolidDatasetAt("https://arbitrary.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][1]?.body as string).not.toMatch("INSERT"); }); it("makes sure the returned SolidDataset has an empty change log", async () => { const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://arbitrary.vocab/subject"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.namedNode("https://arbitrary.vocab/object"), undefined, ), ], deletions: [ DataFactory.quad( DataFactory.namedNode("https://arbitrary-other.vocab/subject"), DataFactory.namedNode("https://arbitrary-other.vocab/predicate"), DataFactory.namedNode("https://arbitrary-other.vocab/object"), undefined, ), ], }, "https://arbitrary.pod/resource", ); const storedSolidDataset = await saveSolidDatasetAt( "https://arbitrary.pod/resource", mockDataset, ); expect(storedSolidDataset.internal_changeLog).toEqual({ additions: [], deletions: [], }); }); it("does not try to create a new Resource if the change log contains no change", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); const resourceInfo: WithResourceInfo["internal_resourceInfo"] = { sourceIri: "https://arbitrary.pod/resource", isRawData: false, }; // Note that the dataset has been fetched from a given IRI, but has no changelog. const mockDataset = { ...createSolidDataset(), internal_resourceInfo: resourceInfo, }; await saveSolidDatasetAt("https://arbitrary.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][1]?.method as string).toBe("PATCH"); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("Not allowed", { status: 403, statusText: "Forbidden", }), ); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://some.vocab/subject"), DataFactory.namedNode("https://some.vocab/predicate"), DataFactory.namedNode("https://some.vocab/object"), undefined, ), ], deletions: [ DataFactory.quad( DataFactory.namedNode("https://some-other.vocab/subject"), DataFactory.namedNode("https://some-other.vocab/predicate"), DataFactory.namedNode("https://some-other.vocab/object"), undefined, ), ], }, "https://some.pod/resource", ); const fetchPromise = saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toThrow( /Storing the Resource at \[https:\/\/some.pod\/resource\] failed: \[403\] \[Forbidden\].*[\s\S]*The changes that were sent to the Pod are listed below/m, ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response("Not found", { status: 404, statusText: "Not Found" }), ); const mockDataset = getMockUpdatedDataset( { additions: [ DataFactory.quad( DataFactory.namedNode("https://some.vocab/subject"), DataFactory.namedNode("https://some.vocab/predicate"), DataFactory.namedNode("https://some.vocab/object"), undefined, ), ], deletions: [ DataFactory.quad( DataFactory.namedNode("https://some-other.vocab/subject"), DataFactory.namedNode("https://some-other.vocab/predicate"), DataFactory.namedNode("https://some-other.vocab/object"), undefined, ), ], }, "https://some.pod/resource", ); const fetchPromise = saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toThrow( /Storing the Resource at \[https:\/\/some.pod\/resource\] failed: \[404\] \[Not Found\].*[\s\S]*The changes that were sent to the Pod are listed below/m, ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("I'm a teapot!", { status: 418, statusText: "I'm a teapot!", }), ); const mockDataset = getMockUpdatedDataset( { additions: [], deletions: [], }, "https://arbitrary.pod/resource", ); const fetchPromise = saveSolidDatasetAt( "https://arbitrary.pod/resource", mockDataset, { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); }); }); describe("deleteSolidDataset", () => { it("should DELETE a remote SolidDataset using the included fetcher if no other fetcher is available", async () => { jest .spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(undefined, { status: 200, statusText: "Deleted" }), ); const response = await deleteSolidDataset("https://example.org/resource"); expect(fetch).toHaveBeenCalledWith("https://example.org/resource", { method: "DELETE", }); expect(response).toBeUndefined(); }); it("should DELETE a remote SolidDataset using the provided fetcher", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response(undefined, { status: 200, statusText: "Deleted" }), ); const response = await deleteSolidDataset("https://example.org/resource", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ [ "https://example.org/resource", { method: "DELETE", }, ], ]); expect(response).toBeUndefined(); }); describe("normalizing the target URL", () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); it("removes double slashes from path", async () => { await deleteSolidDataset("https://some.pod//resource//path", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource/path"); }); it("leaves input trailing slashes unchanged", async () => { await deleteSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); await deleteSolidDataset("https://some.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/container/"); }); it("removes relative path components", async () => { await deleteSolidDataset("https://some.pod/././container/test/../", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); }); it("should accept a fetched SolidDataset as target", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response(undefined, { status: 200, statusText: "Deleted" }), ); const mockSolidDataset = mockSolidDatasetFrom("https://some.url"); const response = await deleteSolidDataset(mockSolidDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ [ "https://some.url", { method: "DELETE", }, ], ]); expect(response).toBeUndefined(); }); it("should throw an error on a failed request", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { status: 400, statusText: "Bad request", }), ); const deletionPromise = deleteSolidDataset("https://example.org/resource", { fetch: mockFetch, }); await expect(deletionPromise).rejects.toThrow( "Deleting the SolidDataset at [https://example.org/resource] failed: [400] [Bad request]", ); }); it("includes the status code, status message and response body when a request failed", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("Teapots don't make coffee", { status: 418, statusText: "I'm a teapot!", }), ); const deletionPromise = deleteSolidDataset("https://arbitrary.url", { fetch: mockFetch, }); await expect(deletionPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringMatching("Teapots don't make coffee"), }); }); }); describe("createContainerAt", () => { it("calls the included fetcher by default", async () => { await createContainerAt("https://some.pod/container/"); expect(fetch).toHaveBeenCalledWith( "https://some.pod/container/", expect.anything(), ); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); await createContainerAt("https://some.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); it("can be called with NamedNodes", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); await createContainerAt( DataFactory.namedNode("https://some.pod/container/"), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); describe("normalizing the target URL", () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); it("removes double slashes from path", async () => { await createContainerAt("https://some.pod//container//path///", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( "https://some.pod/container/path/", ); }); it("appends a trailing slash if not provided", async () => { await createContainerAt("https://some.pod/container", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); it("removes relative path components", async () => { await createContainerAt("https://some.pod/././container/test/../", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); }); it("sets the right headers to create a Container", async () => { const mockFetch = jest .fn() .mockReturnValue(Promise.resolve(new Response())); await createContainerAt("https://some.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); expect(mockFetch.mock.calls[0][1]?.method).toBe("PUT"); expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty( "Link", '; rel="type"', ); expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty( "Content-Type", "text/turtle", ); expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty( "If-None-Match", "*", ); }); it("keeps track of what URL the Container was saved to", async () => { const mockFetch = jest .fn() .mockResolvedValue( mockResponse(undefined, {}, "https://some.pod/container/"), ); const solidDataset = await createContainerAt( "https://some.pod/container/", { fetch: mockFetch, }, ); expect(solidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/container/", ); }); it("provides the IRI of the relevant ACL resource, if provided", async () => { const mockFetch = jest.fn().mockResolvedValue( mockResponse( undefined, { headers: { Link: '; rel="acl"', "Content-Type": "text/turtle", }, }, "https://some.pod/container/", ), ); const solidDataset = await createContainerAt( "https://some.pod/container/", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.aclUrl).toBe( "https://some.pod/container/aclresource.acl", ); }); it("does not provide an IRI to an ACL resource if not provided one by the server", async () => { const mockFetch = jest.fn().mockResolvedValue( mockResponse( undefined, { headers: { Link: '; rel="not-acl"', }, }, "https://arbitrary.pod", ), ); const solidDataset = await createContainerAt( "https://some.pod/container/", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.aclUrl).toBeUndefined(); }); it("provides the relevant access permissions to the Resource, if available", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { "Wac-Allow": 'public="read",user="read write append control"', }, }), ); const solidDataset = await createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.permissions).toEqual({ user: { read: true, append: true, write: true, control: true, }, public: { read: true, append: false, write: false, control: false, }, }); }); it("defaults permissions to false if they are not set, or are set with invalid syntax", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { // Public permissions are missing double quotes, user permissions are absent: "WAC-Allow": "public=read", }, }), ); const solidDataset = await createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.permissions).toEqual({ user: { read: false, append: false, write: false, control: false, }, public: { read: false, append: false, write: false, control: false, }, }); }); it("does not provide the resource's access permissions if not provided by the server", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: {}, }), ); const solidDataset = await createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch }, ); expect(solidDataset.internal_resourceInfo.permissions).toBeUndefined(); }); it("returns an empty SolidDataset", async () => { const mockFetch = jest .fn() .mockResolvedValue( mockResponse(undefined, {}, "https://arbitrary.pod/container/"), ); const solidDataset = await createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch, }, ); expect(solidDataset.graphs.default).toStrictEqual({}); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response("Not allowed", { status: 403, statusText: "Forbidden" }), ); const fetchPromise = createContainerAt("https://some.pod/container/", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Creating the empty Container at \[https:\/\/some.pod\/container\/\] failed: \[403\] \[Forbidden\]/, ); }); it("includes the status code, status message and response body when a request failed", async () => { const mockFetch = jest.fn().mockResolvedValue( new Response("Teapots don't make coffee", { status: 418, statusText: "I'm a teapot!", }), ); const fetchPromise = createContainerAt("https://arbitrary.pod/container/", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringContaining("Teapots don't make coffee"), }); }); describe("Creating non-empty container", () => { it("returns an non-empty SolidDataset if one is provided", async () => { const mockFetch = jest .fn() .mockResolvedValue( mockResponse(undefined, {}, "https://arbitrary.pod/container/"), ); const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); const mockDataset = setThing(createSolidDataset(), mockThing); const returnedContainer = await createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch, initialContent: mockDataset, }, ); // All the things in the initial content should be in the returned dataset. expect( getThingAll(mockDataset).every( (thing) => getThing(returnedContainer, asIri(thing)) !== null, ), ).toBe(true); // The initial content should be sent when creating the container expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( " .", ); }); it("reports HTTP error when an non-empty SolidDataset provided", async () => { const mockFetch = jest .fn() // Mock an error response to the request. .mockResolvedValueOnce(new Response("Forbidden", { status: 403 })); const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); const mockDataset = setThing(createSolidDataset(), mockThing); await expect( createContainerAt("https://arbitrary.pod/container/", { fetch: mockFetch, initialContent: mockDataset, }), ).rejects.toThrow("Creating the non-empty Container"); }); }); }); describe("saveSolidDatasetInContainer", () => { it("calls the included fetcher by default", async () => { await saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), ); // Two calls expected: one to store the dataset, one to retrieve its details // (e.g. Linked Resources). expect(fetch).toHaveBeenCalledTimes(1); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest.fn(fetchMockImplementation); await saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: mockFetch, }, ); // Two calls expected: one to store the dataset, one to retrieve its details // (e.g. Linked Resources). expect(mockFetch).toHaveBeenCalledTimes(1); }); describe("normalizing the target URL", () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }), ); it("removes double slashes from path", async () => { await saveSolidDatasetInContainer( "https://some.pod//container//path///", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( "https://some.pod/container/path/", ); }); it("enforces a trailing slash is present", async () => { await saveSolidDatasetInContainer( "https://some.pod/container", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); it("removes relative path components", async () => { await saveSolidDatasetInContainer( "https://some.pod/././container/test/../", createSolidDataset(), { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); }); it("returns a meaningful error when the server returns a 403", async () => { const fetchPromise = saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: async () => new Response("Not allowed", { status: 403, statusText: "Forbidden", }), }, ); await expect(fetchPromise).rejects.toThrow( /Storing the Resource in the Container at \[https:\/\/some.pod\/container\/\] failed: \[403\] \[Forbidden\]/, ); }); it("returns a meaningful error when the server returns a 404", async () => { const fetchPromise = saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: async () => new Response("Not found", { status: 404, statusText: "Not Found", }), }, ); await expect(fetchPromise).rejects.toThrow( /Storing the Resource in the Container at \[https:\/\/some.pod\/container\/\] failed: \[404\] \[Not Found\]/, ); }); it("returns a meaningful error when the server does not return the new Resource's location", async () => { const fetchPromise = saveSolidDatasetInContainer( "https://arbitrary.pod/container/", createSolidDataset(), { fetch: async () => new Response(), }, ); await expect(fetchPromise).rejects.toThrow( new Error( "Could not determine the location of the newly saved SolidDataset.", ), ); }); it("includes the status code, status message and response body when a request failed", async () => { const fetchPromise = saveSolidDatasetInContainer( "https://arbitrary.pod/container/", createSolidDataset(), { fetch: async () => new Response("Teapots don't make coffee", { status: 418, statusText: "I'm a teapot!", }), }, ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringContaining("Teapots don't make coffee"), }); }); it("sends the given SolidDataset to the Pod", async () => { const mockFetch = jest.fn(fetchMockImplementation); const mockThing = addUrl( createThing({ url: "https://arbitrary.vocab/subject" }), "https://arbitrary.vocab/predicate", "https://arbitrary.vocab/object", ); const mockDataset = setThing(createSolidDataset(), mockThing); await saveSolidDatasetInContainer( "https://some.pod/container/", mockDataset, { fetch: mockFetch, }, ); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); expect(mockFetch.mock.calls[0][1]?.method).toBe("POST"); expect( (mockFetch.mock.calls[0][1]?.headers as Record)[ "Content-Type" ], ).toBe("text/turtle"); expect( (mockFetch.mock.calls[0][1]?.headers as Record).Link, ).toBe('; rel="type"'); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( " .", ); }); it("sets relative IRIs for LocalNodes", async () => { const mockFetch = jest.fn(fetchMockImplementation); const mockObjectThing = createThing({ name: "some-object-name" }); const mockThing = addUrl( createThing({ name: "some-subject-name" }), "https://arbitrary.vocab/predicate", mockObjectThing, ); const mockDataset = setThing(createSolidDataset(), mockThing); await saveSolidDatasetInContainer( "https://some.pod/container/", mockDataset, { fetch: mockFetch, }, ); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( "<#some-subject-name> <#some-object-name>.", ); }); it("sends the suggested slug to the Pod", async () => { const mockFetch = jest.fn(fetchMockImplementation); await saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: mockFetch, slugSuggestion: "some-slug", }, ); expect(mockFetch.mock.calls[0][1]?.headers).toMatchObject({ slug: "some-slug", }); }); it("does not send a suggested slug if none was provided", async () => { const mockFetch = jest.fn(fetchMockImplementation); await saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: mockFetch, }, ); expect( (mockFetch.mock.calls[0][1]?.headers as Record).slug, ).toBeUndefined(); }); it("includes the final slug with the return value", async () => { const savedSolidDataset = await saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: async () => new Response("Arbitrary response", { headers: { Location: "https://some.pod/container/resource" }, }), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/container/resource", ); }); it("resolves relative IRIs in the returned SolidDataset", async () => { const mockObjectThing = createThing({ name: "some-object-name" }); const mockThing = addUrl( createThing({ name: "some-subject-name" }), "https://arbitrary.vocab/predicate", mockObjectThing, ); const mockDataset = setThing(createSolidDataset(), mockThing); const storedSolidDataset = await saveSolidDatasetInContainer( "https://some.pod/", mockDataset, { fetch: async () => Object.defineProperty( new Response(null, { status: 201, statusText: "Created", headers: { Location: "/url" }, }), "url", { value: "https://saved.at/url" }, ), }, ); const storedThing = getThing( storedSolidDataset, "https://saved.at/url#some-subject-name", ); expect(storedThing).not.toBeNull(); expect(getUrl(storedThing!, "https://arbitrary.vocab/predicate")).toBe( "https://saved.at/url#some-object-name", ); }); it("includes the final slug with the return value, normalised to the Container's origin", async () => { const savedSolidDataset = await saveSolidDatasetInContainer( "https://some.pod/container/", createSolidDataset(), { fetch: async () => Object.defineProperty( new Response("Arbitrary response", { headers: { Location: "/container/resource" }, }), "url", { value: "https://some.pod/" }, ), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/container/resource", ); }); }); describe("createContainerInContainer", () => { it("calls the included fetcher by default", async () => { await createContainerInContainer("https://some.pod/parent-container/"); // Two calls expected: one to store the dataset, one to retrieve its details // (e.g. Linked Resources). expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith("https://some.pod/parent-container/", { method: "POST", headers: { "Content-Type": "text/turtle", Link: '; rel="type"', }, }); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest.fn(async () => Object.defineProperty( new Response("Arbitrary response", { headers: { Location: "https://some.pod/parent-container/child-container/", }, }), "url", { value: "https://some.pod/parent-container/" }, ), ); await createContainerInContainer("https://some.pod/parent-container/", { fetch: mockFetch, }); // Two calls expected: one to store the dataset, one to retrieve its details // (e.g. Linked Resources). expect(mockFetch).toHaveBeenCalledTimes(1); }); describe("normalizing the target URL", () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }), ); it("removes double slashes from path", async () => { await createContainerInContainer("https://some.pod//container//path///", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( "https://some.pod/container/path/", ); }); it("enforces a trailing slash is present", async () => { await createContainerInContainer("https://some.pod/container", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); it("removes relative path components", async () => { await createContainerInContainer( "https://some.pod/././container/test/../", { fetch: mockFetch, }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); }); it("returns a meaningful error when the server returns a 403", async () => { const fetchPromise = createContainerInContainer( "https://some.pod/parent-container/", { fetch: async () => new Response("Not allowed", { status: 403, statusText: "Forbidden", }), }, ); await expect(fetchPromise).rejects.toThrow( /Creating an empty Container in the Container at \[https:\/\/some.pod\/parent-container\/\] failed: \[403\] \[Forbidden\]/, ); }); it("returns a meaningful error when the server returns a 404", async () => { const fetchPromise = createContainerInContainer( "https://some.pod/parent-container/", { fetch: async () => new Response("Not found", { status: 404, statusText: "Not Found", }), }, ); await expect(fetchPromise).rejects.toThrow( /Creating an empty Container in the Container at \[https:\/\/some.pod\/parent-container\/] failed: \[404\] \[Not Found\]./, ); }); it("returns a meaningful error when the server does not return the new Container's location", async () => { const fetchPromise = createContainerInContainer( "https://arbitrary.pod/parent-container/", { fetch: async () => new Response(), }, ); await expect(fetchPromise).rejects.toThrow( new Error( "Could not determine the location of the newly created Container.", ), ); }); it("includes the status code, status message and response body when a request failed", async () => { const fetchPromise = createContainerInContainer( "https://arbitrary.pod/parent-container/", { fetch: async () => new Response("Teapots don't make coffee", { status: 418, statusText: "I'm a teapot!", }), }, ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringContaining("Teapots don't make coffee"), }); }); it("sends the suggested slug to the Pod", async () => { const mockFetch = jest.fn(fetchMockImplementation); await createContainerInContainer("https://some.pod/parent-container/", { fetch: mockFetch, slugSuggestion: "child-container/", }); expect(mockFetch.mock.calls[0][1]?.headers).toMatchObject({ slug: "child-container/", }); }); it("includes the final slug with the return value", async () => { const savedSolidDataset = await createContainerInContainer( "https://some.pod/parent-container/", { fetch: async () => new Response("Arbitrary response", { headers: { Location: "https://some.pod/parent-container/child-container/", }, }), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/parent-container/child-container/", ); }); it("uses the full location URL if absolute thereby applying server-side normalisation", async () => { const savedSolidDataset = await createContainerInContainer( "https://some.pod/parent-container//", { fetch: async () => Object.defineProperty( new Response("Arbitrary response", { headers: { Location: "https://some.pod/parent-container/child-container/", }, }), "url", { value: "https://some.pod/parent-container//" }, ), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/parent-container/child-container/", ); }); it("uses the relative location URL to indicate source IRI", async () => { const savedSolidDataset = await createContainerInContainer( "https://some.pod/parent-container//a/b", { fetch: async () => Object.defineProperty( new Response("Arbitrary response", { headers: { Location: "./x/y/", }, }), "url", { value: "https://some.pod/parent-container//a/b" }, ), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/parent-container//a/x/y/", ); }); it("includes the final slug with the return value, normalised to the target Container's origin", async () => { const savedSolidDataset = await createContainerInContainer( "https://some.pod/parent-container/", { fetch: async () => Object.defineProperty( new Response("Arbitrary response", { headers: { Location: "/parent-container/child-container/", }, }), "url", { value: "https://some.pod/parent-container/" }, ), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/parent-container/child-container/", ); }); it("includes the final slug with the return value, relative to the target Container", async () => { const savedSolidDataset = await createContainerInContainer( "https://some.pod/parent-container/", { fetch: async () => Object.defineProperty( new Response("Arbitrary response", { headers: { Location: "child-container/", "Content-Location": "https://some.pod/parent-container/child-container/", }, }), "url", { value: "https://some.pod/parent-container/" }, ), }, ); expect(savedSolidDataset!.internal_resourceInfo.sourceIri).toBe( "https://some.pod/parent-container/child-container/", ); }); }); describe("deleteContainer", () => { it("should DELETE a remote Container using the included fetcher if no other fetcher is available", async () => { jest .spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(undefined, { status: 200, statusText: "Deleted" }), ); const response = await deleteContainer("https://some.pod/container/"); expect(fetch).toHaveBeenCalledWith("https://some.pod/container/", { method: "DELETE", }); expect(fetch).toHaveBeenCalledTimes(1); expect(response).toBeUndefined(); }); it("should DELETE a remote Container using the provided fetcher", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response(undefined, { status: 200, statusText: "Deleted" }), ); const response = await deleteContainer("https://some.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ [ "https://some.pod/container/", { method: "DELETE", }, ], ]); expect(response).toBeUndefined(); }); describe("normalizing the target URL", () => { const mockFetch = jest.fn().mockResolvedValue( new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }), ); it("removes double slashes from path", async () => { await deleteContainer("https://some.pod//container//path///", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( "https://some.pod/container/path/", ); }); it("removes relative path components", async () => { await deleteContainer("https://some.pod/././container/test/../", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/"); }); }); it("should accept a fetched Container as target", async () => { const mockFetch = jest .fn() .mockResolvedValue( new Response(undefined, { status: 200, statusText: "Deleted" }), ); const mockContainer = mockSolidDatasetFrom("https://some.pod/container/"); const response = await deleteContainer(mockContainer, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ [ "https://some.pod/container/", { method: "DELETE", }, ], ]); expect(response).toBeUndefined(); }); it("should throw an error when the target is not a Container", async () => { const mockSolidDataset = mockSolidDatasetFrom("https://some.pod/resource"); const deletionPromise = deleteContainer(mockSolidDataset); await expect(deletionPromise).rejects.toThrow( "You're trying to delete the Container at [https://some.pod/resource], but Container URLs should end in a `/`. Are you sure this is a Container?", ); }); it("should throw an error on a failed request", async () => { const deletionPromise = deleteContainer("https://some.pod/container/", { fetch: async () => new Response(undefined, { status: 400, statusText: "Bad request", }), }); await expect(deletionPromise).rejects.toThrow( "Deleting the Container at [https://some.pod/container/] failed: [400] [Bad request]", ); }); it("includes the status code, status message and response body when a request failed", async () => { const deletionPromise = deleteContainer( "https://arbitrary.pod/container/", { fetch: async () => new Response("Teapots don't make coffee", { status: 418, statusText: "I'm a teapot!", }), }, ); await expect(deletionPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", message: expect.stringContaining("Teapots don't make coffee"), }); }); }); describe("getContainedResourceUrlAll", () => { const mockContainer = ( containerUrl: string, containedResourceUrls: UrlString[], ) => { let childrenIndex = createThing({ url: containerUrl }); let mockedContainer = mockContainerFrom(containerUrl); containedResourceUrls.forEach((resourceUrl) => { let childListing = createThing({ url: resourceUrl, }); childListing = addUrl(childListing, rdf.type, ldp.Resource); childrenIndex = addUrl(childrenIndex, ldp.contains, childListing); mockedContainer = setThing(mockedContainer, childListing); }); mockedContainer = setThing(mockedContainer, childrenIndex); childrenIndex = addUrl(childrenIndex, rdf.type, ldp.Container); return mockedContainer; }; it("gets all URLs for contained Resources from a Container", () => { const containerUrl = "https://arbitrary.pod/container/"; const containedThings = [ "https://arbitrary.pod/container/resource1", "https://arbitrary.pod/container/resource2/", ]; const container = mockContainer(containerUrl, containedThings); expect(getContainedResourceUrlAll(container)).toStrictEqual( containedThings, ); expect(validateContainedResourceAll(container)).toStrictEqual({ isValid: true, invalidContainedResources: [], }); }); it("does not include non-direct children of target Container", () => { const containerUrl = "https://arbitrary.pod/container/"; const indirectChildren = [ "https://arbitrary.pod/container/container/resource1/", "https://arbitrary.pod/container//c/resource2", "https://arbitrary.pod/resource3", "https://other.pod/container/resource4", ]; expect( getContainedResourceUrlAll(mockContainer(containerUrl, indirectChildren)), ).toHaveLength(0); expect( validateContainedResourceAll( mockContainer(containerUrl, indirectChildren), ), ).toStrictEqual({ isValid: false, invalidContainedResources: [...indirectChildren], }); }); it("does not include children having a similar URL path as the parent", () => { expect( getContainedResourceUrlAll( mockContainer("http://example.org/a/", ["http://example.org/a/"]), ), ).toHaveLength(0); expect( validateContainedResourceAll( mockContainer("http://example.org/a/", ["http://example.org/a/"]), ), ).toStrictEqual({ isValid: false, invalidContainedResources: ["http://example.org/a/"], }); expect( getContainedResourceUrlAll( mockContainer("http://example.org/a/?q1=a/", [ "http://example.org/a/?q1=a/a", ]), ), ).toHaveLength(0); expect( validateContainedResourceAll( mockContainer("http://example.org/a/?q1=a/", [ "http://example.org/a/?q1=a/a", ]), ), ).toStrictEqual({ isValid: false, invalidContainedResources: ["http://example.org/a/?q1=a/a"], }); expect( getContainedResourceUrlAll( mockContainer("http://example.org/a/", ["http://example.org/a//"]), ), ).toHaveLength(0); expect( validateContainedResourceAll( mockContainer("http://example.org/a/", ["http://example.org/a//"]), ), ).toStrictEqual({ isValid: false, invalidContainedResources: ["http://example.org/a//"], }); expect( getContainedResourceUrlAll( mockContainer("http://example.org/a/", ["http://example.org/a/../"]), ), ).toHaveLength(0); expect( validateContainedResourceAll( mockContainer("http://example.org/a/", ["http://example.org/a/../"]), ), ).toStrictEqual({ isValid: false, invalidContainedResources: ["http://example.org/a/../"], }); }); it("returns an empty array if the Container contains no Resources", () => { const containerUrl = "https://arbitrary.pod/container/"; const containedThings: UrlString[] = []; const container = mockContainer(containerUrl, containedThings); expect(getContainedResourceUrlAll(container)).toStrictEqual( containedThings, ); }); it("returns an empty array if the Container contains no index of contained Resources", () => { const dataset = mockSolidDatasetFrom("https://arbitrary.pod/dataset"); expect(getContainedResourceUrlAll(dataset)).toStrictEqual([]); expect(validateContainedResourceAll(dataset)).toStrictEqual({ isValid: true, invalidContainedResources: [], }); }); }); describe("solidDatasetAsMarkdown", () => { it("returns a readable version of an empty, unsaved SolidDataset", () => { const emptyDataset = createSolidDataset(); expect(solidDatasetAsMarkdown(emptyDataset)).toBe( "# SolidDataset (no URL yet)\n\n\n", ); }); it("returns a readable version of an empty SolidDataset with a known URL", () => { const datasetWithSourceUrl = mockSolidDatasetFrom( "https://some.pod/resource", ); expect(solidDatasetAsMarkdown(datasetWithSourceUrl)).toBe( "# SolidDataset: https://some.pod/resource\n\n\n", ); }); it("returns a readable version of a SolidDataset that contains an unsaved Thing", () => { let thing = createThing({ name: "thing" }); thing = addStringNoLocale( thing, "https://some.vocab/predicate", "Some string", ); thing = addStringNoLocale( thing, "https://some.vocab/predicate", "Some other string", ); const datasetWithUnsavedThing = setThing(createSolidDataset(), thing); expect(solidDatasetAsMarkdown(datasetWithUnsavedThing)).toBe( "# SolidDataset (no URL yet)\n\n" + "## Thing (no URL yet — identifier: `#thing`)\n\n" + "Property: https://some.vocab/predicate\n" + '- "Some string" (string)\n' + '- "Some other string" (string)\n\n' + "(2 new values added / 0 values removed)\n", ); }); it("returns a readable version of a SolidDataset that contains a fetched Thing that has been changed", () => { let thing = mockThingFrom("https://some.pod/resource#thing"); thing = addStringNoLocale( thing, "https://some.vocab/predicate", "Some string", ); thing = addStringNoLocale( thing, "https://some.vocab/predicate", "Some other string", ); let datasetWithSavedThing = setThing( mockSolidDatasetFrom("https://some.pod/resource"), thing, ); // Pretend that datasetWithSavedThing was fetched from the Pod with its current contents: datasetWithSavedThing = { ...datasetWithSavedThing, internal_changeLog: { additions: [], deletions: [] }, }; let changedThing = addStringNoLocale( thing, "https://some.vocab/predicate", "Yet another string", ); changedThing = removeStringNoLocale( changedThing, "https://some.vocab/predicate", "Some other string", ); const changedDataset = setThing(datasetWithSavedThing, changedThing); expect(solidDatasetAsMarkdown(changedDataset)).toBe( "# SolidDataset: https://some.pod/resource\n\n" + "## Thing: https://some.pod/resource#thing\n\n" + "Property: https://some.vocab/predicate\n" + '- "Some string" (string)\n' + '- "Yet another string" (string)\n\n' + "(1 new value added / 1 value removed)\n", ); }); it("returns a readable version of a SolidDataset that contains multiple Things", () => { let thing1 = createThing({ name: "thing1" }); thing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Some string", ); let thing2 = createThing({ name: "thing2" }); thing2 = addStringNoLocale( thing2, "https://some.vocab/predicate", "Some other string", ); let datasetWithMultipleThings = setThing(createSolidDataset(), thing1); datasetWithMultipleThings = setThing(datasetWithMultipleThings, thing2); expect(solidDatasetAsMarkdown(datasetWithMultipleThings)).toBe( "# SolidDataset (no URL yet)\n\n" + "## Thing (no URL yet — identifier: `#thing1`)\n\n" + "Property: https://some.vocab/predicate\n" + '- "Some string" (string)\n\n' + "(1 new value added / 0 values removed)\n\n" + "## Thing (no URL yet — identifier: `#thing2`)\n\n" + "Property: https://some.vocab/predicate\n" + '- "Some other string" (string)\n\n' + "(1 new value added / 0 values removed)\n", ); }); it("returns a readable version of a SolidDataset that contains multiple fetched Things that have been changed", () => { let thing1 = mockThingFrom("https://some.pod/resource#thing1"); thing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Some string", ); thing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Some other string", ); let thing2 = mockThingFrom("https://some.pod/resource#thing2"); thing2 = addStringNoLocale( thing2, "https://some.vocab/predicate", "Some string", ); thing2 = addStringNoLocale( thing2, "https://some.vocab/predicate", "Some other string", ); let datasetWithSavedThings = setThing( mockSolidDatasetFrom("https://some.pod/resource"), thing1, ); datasetWithSavedThings = setThing(datasetWithSavedThings, thing2); // Pretend that datasetWithSavedThing was fetched from the Pod with its current contents: datasetWithSavedThings = { ...datasetWithSavedThings, internal_changeLog: { additions: [], deletions: [], }, }; let changedThing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Yet another string", ); changedThing1 = removeStringNoLocale( changedThing1, "https://some.vocab/predicate", "Some other string", ); const changedThing2 = removeStringNoLocale( thing2, "https://some.vocab/predicate", "Some other string", ); let changedDataset = setThing(datasetWithSavedThings, changedThing1); changedDataset = setThing(changedDataset, changedThing2); expect(solidDatasetAsMarkdown(changedDataset)).toBe( "# SolidDataset: https://some.pod/resource\n\n" + "## Thing: https://some.pod/resource#thing1\n\n" + "Property: https://some.vocab/predicate\n" + '- "Some string" (string)\n' + '- "Yet another string" (string)\n\n' + "(1 new value added / 1 value removed)\n\n" + "## Thing: https://some.pod/resource#thing2\n\n" + "Property: https://some.vocab/predicate\n" + '- "Some string" (string)\n\n' + "(0 new values added / 1 value removed)\n", ); }); it("does not show a list of changes if none is available", () => { const mockThing = addStringNoLocale( createThing({ url: "https://arbitrary.pod/resource#thing" }), "https://arbitrary.vocab/predicate", "Arbitrary string", ); const datasetWithoutChangeLog = { ...setThing(createSolidDataset(), mockThing), internal_changeLog: undefined, }; expect(solidDatasetAsMarkdown(datasetWithoutChangeLog)).toBe( "# SolidDataset (no URL yet)\n\n" + "## Thing: https://arbitrary.pod/resource#thing\n\n" + "Property: https://arbitrary.vocab/predicate\n" + '- "Arbitrary string" (string)\n', ); }); }); describe("changeLogAsMarkdown", () => { it("returns a readable version of an in-memory-only SolidDataset", () => { const freshDataset = createSolidDataset(); expect( changeLogAsMarkdown( freshDataset as unknown as SolidDataset & WithChangeLog, ), ).toBe( "This is a newly initialized SolidDataset, so there is no source to compare it to.", ); }); it("returns a readable version of a SolidDataset that has not been changed yet", () => { const unchangedDataset = mockSolidDatasetFrom("https://some.pod/resource"); expect( changeLogAsMarkdown( unchangedDataset as unknown as SolidDataset & WithChangeLog, ), ).toBe( "## Changes compared to https://some.pod/resource\n\n" + "This SolidDataset has not been modified since it was fetched from https://some.pod/resource.\n", ); }); it("returns a readable version of a SolidDataset that had changes that were undone again", () => { let thing = mockThingFrom("https://arbitrary.pod/resource#thing"); thing = addStringNoLocale( thing, "https://arbitrary.vocab/predicate", "Arbitrary string", ); const newDataset = mockSolidDatasetFrom("https://some.pod/resource"); const changedDataset = setThing(newDataset, thing); const undoneThing = removeStringNoLocale( thing, "https://arbitrary.vocab/predicate", "Arbitrary string", ); const undoneDataset = setThing(changedDataset, undoneThing); expect(changeLogAsMarkdown(undoneDataset)).toBe( "## Changes compared to https://some.pod/resource\n\n" + "This SolidDataset has not been modified since it was fetched from https://some.pod/resource.\n", ); }); it("returns a readable version of a SolidDataset that had a new Thing with a local Subject added to it", () => { let thing = createThing({ name: "some-new-thing" }); thing = addStringNoLocale( thing, "https://arbitrary.vocab/predicate", "Arbitrary string", ); const newDataset = mockSolidDatasetFrom("https://some.pod/resource"); const fetchedDataset = { ...newDataset, internal_changeLog: { additions: [], deletions: [], }, }; const changedDataset = setThing(fetchedDataset, thing); expect(changeLogAsMarkdown(changedDataset)).toBe( "## Changes compared to https://some.pod/resource\n\n" + "### Thing: https://some.pod/resource#some-new-thing\n\n" + "Property: https://arbitrary.vocab/predicate\n" + "- Added:\n" + ' - "Arbitrary string" (string)\n', ); }); it("returns a readable version of local changes to a SolidDataset", () => { let thing1 = mockThingFrom("https://some.pod/resource#thing1"); thing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Some string", ); thing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Some other string", ); let thing2 = mockThingFrom("https://some.pod/resource#thing2"); thing2 = addStringNoLocale( thing2, "https://some.vocab/predicate", "Some string", ); thing2 = addStringNoLocale( thing2, "https://some.vocab/predicate", "Some other string", ); let datasetWithSavedThings = setThing( mockSolidDatasetFrom("https://some.pod/resource"), thing1, ); datasetWithSavedThings = setThing(datasetWithSavedThings, thing2); // Pretend that datasetWithSavedThing was fetched from the Pod with its current contents: datasetWithSavedThings = { ...datasetWithSavedThings, internal_changeLog: { additions: [], deletions: [], }, }; let changedThing1 = addStringNoLocale( thing1, "https://some.vocab/predicate", "Yet another string", ); changedThing1 = removeStringNoLocale( changedThing1, "https://some.vocab/predicate", "Some other string", ); let changedThing2 = removeStringNoLocale( thing2, "https://some.vocab/predicate", "Some string", ); changedThing2 = removeStringNoLocale( changedThing2, "https://some.vocab/predicate", "Some other string", ); let changedDataset = setThing(datasetWithSavedThings, changedThing1); changedDataset = setThing(changedDataset, changedThing2); let newThing = createThing({ name: "thing3" }); newThing = addStringNoLocale( newThing, "https://some.vocab/predicate", "Some string", ); changedDataset = setThing(changedDataset, newThing); expect(changeLogAsMarkdown(changedDataset)).toBe( "## Changes compared to https://some.pod/resource\n\n" + "### Thing: https://some.pod/resource#thing1\n\n" + "Property: https://some.vocab/predicate\n" + "- Removed:\n" + ' - "Some other string" (string)\n' + "- Added:\n" + ' - "Yet another string" (string)\n\n' + "### Thing: https://some.pod/resource#thing2\n\n" + "Property: https://some.vocab/predicate\n" + "- Removed:\n" + ' - "Some string" (string)\n' + ' - "Some other string" (string)\n\n' + "### Thing: https://some.pod/resource#thing3\n\n" + "Property: https://some.vocab/predicate\n" + "- Added:\n" + ' - "Some string" (string)\n', ); }); it("does not choke on invalid Quads that found their way into the ChangeLog", () => { let thing = createThing({ name: "thing" }); thing = addStringNoLocale( thing, "https://some.vocab/predicate", "Some string", ); const datasetWithInvalidChangeLog = setThing( mockSolidDatasetFrom("https://some.pod/resource"), thing, ); // Pretend that the deletions and additions contain the same Quad: datasetWithInvalidChangeLog.internal_changeLog.deletions.push( datasetWithInvalidChangeLog.internal_changeLog.additions[0], ); const quadWithInvalidSubject = DataFactory.quad( // We'd never use a variable as the Subject: DataFactory.variable("Arbitrary variable name"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.literal("Arbitrary object"), ); const quadWithInvalidPredicate = DataFactory.quad( DataFactory.namedNode("https://arbitrary.pod/resource#thing"), // Predicates should always be NamedNodes: DataFactory.literal("Not a NamedNode") as any, DataFactory.literal("Arbitrary object"), ); datasetWithInvalidChangeLog.internal_changeLog.additions.push( quadWithInvalidSubject, quadWithInvalidPredicate, ); datasetWithInvalidChangeLog.internal_changeLog.deletions.push( quadWithInvalidSubject, quadWithInvalidPredicate, ); expect(changeLogAsMarkdown(datasetWithInvalidChangeLog)).toBe( "## Changes compared to https://some.pod/resource\n\n" + "### Thing: https://some.pod/resource#thing\n\n" + "Property: https://some.vocab/predicate\n" + "- Removed:\n" + ' - "Some string" (string)\n' + "- Added:\n" + ' - "Some string" (string)\n', ); }); }); describe("getWellKnownSolid", () => { const serverUrl = "https://example.org/"; const resourceUrl = "https://example.org/pod/resource"; const wellKnownSolid = ".well-known/solid"; const essDiscoveryDoc: typeof fetch = async () => new Response( `@prefix solid: . [ a solid:DiscoveryDocument ; ; solid:maxPodsPerOwner 10 ; solid:notificationGateway ; solid:provision ; solid:validatesRdfSources true ] .`, { headers: { "Content-Type": "text/turtle", }, }, ); const mockESS = () => jest.spyOn(globalThis, "fetch").mockImplementation(essDiscoveryDoc); it("fetches root well-known solid by default", async () => { // Fetches root well-known mockESS(); await getWellKnownSolid(resourceUrl); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( serverUrl.concat(wellKnownSolid), expect.anything(), ); }); it("Throws an error if the well-known solid resource cannot be found", async () => { const spyFetch = jest.spyOn(globalThis, "fetch"); // Can't fetch root well-known spyFetch.mockResolvedValueOnce(new Response(undefined, { status: 404 })); await expect(getWellKnownSolid(resourceUrl)).rejects.toThrow( "Could not determine storage root or well-known solid resource.", ); expect(spyFetch.mock.calls).toHaveLength(1); }); it("returns the contents of .well-known/solid for the given resource", async () => { mockESS(); const wellKnownSolidResponse = await getWellKnownSolid(resourceUrl); // skipIf confuses jest expect(wellKnownSolidResponse).toMatchSnapshot(); }); });