UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

1,582 lines (1,392 loc) • 106 kB
// 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<Parameters<Parser["onQuad"]>[0]> = []; const onCompleteCallbacks: Array<Parameters<Parser["onComplete"]>[0]> = []; const onErrorCallbacks: Array<Parameters<Parser["onError"]>[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 <https://some.pod/resource> . @prefix : <#>. @prefix foaf: <http://xmlns.com/foaf/0.1/>. @prefix vcard: <http://www.w3.org/2006/vcard/ns#>. <> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. :me a foaf:Person; vcard:fn "Vincent", [:predicate <for://a.blank/node>]. `; 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<string>((resolve) => { resolveDataPromise = resolve; }); jest.spyOn(response, "text").mockReturnValue(dataPromise); const onErrorHandlers: Array<Parameters<Parser["onError"]>[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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>() .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<typeof fetch>() .mockResolvedValueOnce( mockResponse( undefined, { headers: { Link: '<aclresource.acl>; 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<typeof fetch>().mockResolvedValue( mockResponse( undefined, { headers: { Link: '<arbitrary-resource>; 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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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: <http://xmlns.com/foaf/0.1/>. @prefix vcard: <http://www.w3.org/2006/vcard/ns#>. <> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. :me a foaf:Person; vcard:fn "Vincent". `; const mockFetch = jest .fn<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>().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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<string, string>)[ "Content-Type" ], ).toBe("text/turtle"); expect( (mockFetch.mock.calls[0][1]?.headers as Record<string, string>).Link, ).toBe('<http://www.w3.org/ns/ldp#Resource>; rel="type"'); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( "<https://arbitrary.vocab/subject> <https://arbitrary.vocab/predicate> <https://arbitrary.vocab/object>.", ); }); it("uses the response URL to compute the saved resource source IRI", async () => { const mockFetch = jest .fn<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>().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<typeof fetch>() .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<typeof fetch>().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<typeof fetch>() .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<typeof fetch>() .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<string, string>)[ "Content-Type" ], ).toBe("application/sparql-update"); expect((mockFetch.mock.calls[0][1]?.body as string).trim()).toBe( "DELETE DATA {<https://some-other.vocab/subject> <https://some-other.vocab/predicate> <https://some-other.vocab/object>.}; " + "INSERT DATA {<https://some.vocab/subject> <https://some.vocab/predicate> <https://some.vocab/object>.};", ); }); it("uses the response IRI to compute the saved resource IRI", async () => { const mockFetch = jest .fn<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>() .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<typeof fetch>().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<typeof fetch>() .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<typeof fetch>().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" }), );