UNPKG

@inrupt/solid-client

Version:
1,628 lines (1,420 loc) • 90.8 kB
/** * Copyright 2020 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 { jest, describe, it, expect } from "@jest/globals"; import type { Mock } from "jest-mock"; jest.mock("../fetcher.ts", () => ({ fetch: jest.fn().mockImplementation(() => Promise.resolve( new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }) ) ), })); import { Response } from "cross-fetch"; import { DataFactory } from "n3"; import { dataset } from "@rdfjs/dataset"; import { getSolidDataset, saveSolidDatasetAt, saveSolidDatasetInContainer, createSolidDataset, createContainerAt, createContainerInContainer, solidDatasetAsMarkdown, changeLogAsMarkdown, deleteSolidDataset, deleteContainer, getContainedResourceUrlAll, internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465, } from "./solidDataset"; import { WithChangeLog, WithResourceInfo, IriString, SolidDataset, LocalNode, UrlString, } from "../interfaces"; import { mockContainerFrom, mockSolidDatasetFrom } from "./mock"; import { createThing, setThing } from "../thing/thing"; import { mockThingFrom } from "../thing/mock"; import { addStringNoLocale, addUrl } from "../thing/add"; import { removeStringNoLocale } from "../thing/remove"; import { ldp, rdf } from "../constants"; function mockResponse( body?: BodyInit | null, init?: ResponseInit & { url: string } ): Response { return new Response(body, init); } describe("createSolidDataset", () => { it("should initialise a new empty SolidDataset", () => { const solidDataset = createSolidDataset(); expect(solidDataset.size).toBe(0); }); }); describe("getSolidDataset", () => { it("calls the included fetcher by default", async () => { const mockedFetcher = jest.requireMock("../fetcher.ts") as { fetch: jest.Mock< ReturnType<typeof window.fetch>, [RequestInfo, RequestInit?] >; }; await getSolidDataset("https://some.pod/resource"); expect(mockedFetcher.fetch.mock.calls[0][0]).toEqual( "https://some.pod/resource" ); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await getSolidDataset("https://some.pod/resource", { fetch: mockFetch }); expect(mockFetch.mock.calls[0][0]).toEqual("https://some.pod/resource"); }); it("adds an Accept header accepting turtle by default", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await getSolidDataset("https://some.pod/resource", { fetch: mockFetch }); expect(mockFetch.mock.calls[0][1]).toEqual({ headers: { Accept: "text/turtle", }, }); }); it("can be called with NamedNodes", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await getSolidDataset(DataFactory.namedNode("https://some.pod/resource"), { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toEqual("https://some.pod/resource"); }); it("keeps track of where the SolidDataset was fetched from", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve( mockResponse(undefined, { url: "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(window.fetch).mockReturnValue( Promise.resolve( mockResponse(undefined, { headers: { Link: '<aclresource.acl>; rel="acl"', }, url: "https://some.pod/container/resource", }) ) ); 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 mockResponse = new Response(undefined, { headers: { Link: '<arbitrary-resource>; rel="not-acl"', }, url: "https://arbitrary.pod", // We need the type assertion because in non-mock situations, // you cannot set the URL manually: } as ResponseInit); const mockFetch = jest.fn(window.fetch).mockResolvedValue(mockResponse); 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(window.fetch).mockReturnValue( Promise.resolve( new Response(undefined, { headers: { "wac-aLLOW": 'public="read",user="read write append control"', }, }) ) ); 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(window.fetch).mockReturnValue( Promise.resolve( new Response(undefined, { headers: { // Public permissions are missing double quotes, user permissions are absent: "WAC-Allow": "public=read", }, }) ) ); 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(window.fetch).mockReturnValue( Promise.resolve( new Response(undefined, { headers: {}, }) ) ); 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(window.fetch) .mockReturnValue( Promise.resolve( mockResponse(turtle, { url: "https://arbitrary.pod/resource" }) ) ); const solidDataset = await getSolidDataset( "https://arbitrary.pod/resource", { fetch: mockFetch, } ); expect(solidDataset.size).toBe(5); expect(solidDataset).toMatchSnapshot(); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not allowed", { status: 403 })) ); const fetchPromise = getSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( new Error( "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(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not found", { status: 404 })) ); const fetchPromise = getSolidDataset("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( new Error( "Fetching the Resource at [https://some.pod/resource] failed: [404] [Not Found]." ) ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn(window.fetch).mockReturnValue( Promise.resolve( new Response("I'm a teapot!", { 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!", }); }); }); describe("saveSolidDatasetAt", () => { it("calls the included fetcher by default", async () => { const mockedFetcher = jest.requireMock("../fetcher.ts") as { fetch: jest.Mock< ReturnType<typeof window.fetch>, [RequestInfo, RequestInit?] >; }; mockedFetcher.fetch.mockResolvedValue( mockResponse(null, { headers: { Location: "/resource" }, url: "https://some.pod/resource", }) ); await saveSolidDatasetAt("https://some.pod/resource", dataset()); expect(mockedFetcher.fetch.mock.calls).toHaveLength(1); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await saveSolidDatasetAt("https://some.pod/resource", dataset(), { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); }); describe("when saving a new resource", () => { it("sends the given SolidDataset to the Pod", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); const mockDataset = dataset(); mockDataset.add( DataFactory.quad( DataFactory.namedNode("https://arbitrary.vocab/subject"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.namedNode("https://arbitrary.vocab/object"), undefined ) ); await saveSolidDatasetAt("https://some.pod/resource", mockDataset, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toEqual("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(window.fetch) .mockResolvedValue(mockResponse(null, { url: "https://saved.at/url" })); const mockDataset = dataset(); mockDataset.add( DataFactory.quad( DataFactory.namedNode("https://arbitrary.vocab/subject"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.namedNode("https://arbitrary.vocab/object"), undefined ) ); 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(window.fetch) .mockResolvedValue( mockResponse(null, { url: "https://some.irrelevant/url" }) ); const mockDataset = dataset(); const subjectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "some-subject-name", }); const objectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "some-object-name", }); mockDataset.add( DataFactory.quad( subjectLocal, DataFactory.namedNode("https://arbitrary.vocab/predicate"), objectLocal, undefined ) ); 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(window.fetch) .mockResolvedValue(mockResponse(null, { url: "https://saved.at/url" })); const mockDataset = dataset(); const subjectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "some-subject-name", }); const objectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "some-object-name", }); mockDataset.add( DataFactory.quad( subjectLocal, DataFactory.namedNode("https://arbitrary.vocab/predicate"), objectLocal, undefined ) ); const storedSolidDataset = await saveSolidDatasetAt( "https://some.pod/resource", mockDataset, { fetch: mockFetch, } ); expect( storedSolidDataset.match( DataFactory.namedNode("https://saved.at/url#some-subject-name"), null, DataFactory.namedNode("https://saved.at/url#some-object-name") ).size ).toBe(1); }); it("makes sure the returned SolidDataset has an empty change log", async () => { const mockDataset = dataset(); 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(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await saveSolidDatasetAt("https://arbitrary.pod/resource", dataset(), { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][1]?.headers).toMatchObject({ "If-None-Match": "*", }); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not allowed", { status: 403 })) ); const fetchPromise = saveSolidDatasetAt( "https://some.pod/resource", dataset(), { fetch: mockFetch, } ); await expect(fetchPromise).rejects.toThrow( "Storing the Resource at [https://some.pod/resource] failed: [403] [Forbidden].\n\n" + "The SolidDataset that was sent to the Pod is listed below.\n\n" ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not found", { status: 404 })) ); const fetchPromise = saveSolidDatasetAt( "https://some.pod/resource", dataset(), { fetch: mockFetch, } ); await expect(fetchPromise).rejects.toThrow( "Storing the Resource at [https://some.pod/resource] failed: [404] [Not Found].\n\n" + "The SolidDataset that was sent to the Pod is listed below.\n\n" ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn(window.fetch).mockReturnValue( Promise.resolve( new Response("I'm a teapot!", { status: 418, statusText: "I'm a teapot!", }) ) ); const fetchPromise = saveSolidDatasetAt( "https://arbitrary.pod/resource", dataset(), { fetch: mockFetch, } ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); it("tries to create the given SolidDataset on the Pod, even if it has an empty changelog", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); const mockDataset = Object.assign(dataset(), { 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 mockDataset = dataset(); mockDataset.add( DataFactory.quad( DataFactory.namedNode("https://arbitrary.vocab/subject"), DataFactory.namedNode("https://arbitrary.vocab/predicate"), DataFactory.namedNode("https://arbitrary.vocab/object"), undefined ) ); changeLog.additions.forEach((tripleToAdd) => mockDataset.add(tripleToAdd) ); const resourceInfo: WithResourceInfo["internal_resourceInfo"] = { sourceIri: fromUrl, isRawData: false, }; return Object.assign(mockDataset, { internal_changeLog: changeLog, internal_resourceInfo: resourceInfo, }); } it("sends just the change log to the Pod", async () => { const mockFetch = jest .fn(window.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]).toEqual("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(window.fetch) .mockResolvedValue(mockResponse(null, { url: "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(window.fetch) .mockResolvedValue( mockResponse(null, { url: "https://some.irrelevant/url" }) ); const subjectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "some-subject-name", }); const objectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "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(window.fetch) .mockResolvedValue(mockResponse(null, { url: "https://saved.at/url" })); const subjectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "some-subject-name", }); const objectLocal: LocalNode = Object.assign(DataFactory.blankNode(), { internal_name: "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 storedQuads = Array.from(storedSolidDataset); expect(storedQuads[storedQuads.length - 1].subject.value).toBe( "https://saved.at/url#some-subject-name" ); expect(storedQuads[storedQuads.length - 1].object.value).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(window.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]).toEqual( "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(window.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]).toEqual( // 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(window.fetch) .mockResolvedValue( mockResponse(null, { url: "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(window.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(window.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 = Object.assign(dataset(), { 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).toStrictEqual( "PATCH" ); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not allowed", { status: 403 })) ); 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].\n\n" + "The changes that were sent to the Pod are listed below.\n\n" ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not found", { status: 404 })) ); 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].\n\n" + "The changes that were sent to the Pod are listed below.\n\n" ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn(window.fetch).mockReturnValue( Promise.resolve( 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 () => { const fetcher = jest.requireMock("../fetcher") as { fetch: jest.Mock< ReturnType<typeof window.fetch>, [RequestInfo, RequestInit?] >; }; fetcher.fetch.mockResolvedValueOnce( new Response(undefined, { status: 200, statusText: "Deleted" }) ); const response = await deleteSolidDataset("https://some.url"); expect(fetcher.fetch.mock.calls).toEqual([ [ "https://some.url", { method: "DELETE", }, ], ]); expect(response).toBeUndefined(); }); it("should DELETE a remote SolidDataset using the provided fetcher", async () => { const mockFetch = jest .fn(window.fetch) .mockResolvedValue( new Response(undefined, { status: 200, statusText: "Deleted" }) ); const response = await deleteSolidDataset("https://some.url", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ [ "https://some.url", { method: "DELETE", }, ], ]); expect(response).toBeUndefined(); }); it("should accept a fetched SolidDataset as target", async () => { const mockFetch = jest .fn(window.fetch) .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(window.fetch).mockResolvedValue( new Response(undefined, { status: 400, statusText: "Bad request", }) ); const deletionPromise = deleteSolidDataset("https://some.url", { fetch: mockFetch, }); await expect(deletionPromise).rejects.toThrow( "Deleting the SolidDataset at [https://some.url] failed: [400] [Bad request]" ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn(window.fetch).mockResolvedValue( new Response(undefined, { 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!", }); }); }); describe("createContainerAt", () => { it("calls the included fetcher by default", async () => { const mockedFetcher = jest.requireMock("../fetcher.ts") as { fetch: jest.Mock< ReturnType<typeof window.fetch>, [RequestInfo, RequestInit?] >; }; await createContainerAt("https://some.pod/container/"); expect(mockedFetcher.fetch.mock.calls[0][0]).toEqual( "https://some.pod/container/" ); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await createContainerAt("https://some.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toEqual("https://some.pod/container/"); }); it("can be called with NamedNodes", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await createContainerAt( DataFactory.namedNode("https://some.pod/container/"), { fetch: mockFetch, } ); expect(mockFetch.mock.calls[0][0]).toEqual("https://some.pod/container/"); }); it("appends a trailing slash if not provided", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await createContainerAt("https://some.pod/container", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toEqual("https://some.pod/container/"); }); it("sets the right headers to create a Container", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue(Promise.resolve(new Response())); await createContainerAt("https://some.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls[0][0]).toEqual("https://some.pod/container/"); expect(mockFetch.mock.calls[0][1]?.method).toBe("PUT"); expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty( "Link", '<http://www.w3.org/ns/ldp#BasicContainer>; 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(window.fetch) .mockReturnValue( Promise.resolve( mockResponse(undefined, { url: "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(window.fetch).mockReturnValue( Promise.resolve( mockResponse(undefined, { headers: { Link: '<aclresource.acl>; rel="acl"', }, url: "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 mockResponse = new Response(undefined, { headers: { Link: '<arbitrary-resource>; rel="not-acl"', }, url: "https://arbitrary.pod", // We need the type assertion because in non-mock situations, // you cannot set the URL manually: } as ResponseInit); const mockFetch = jest.fn(window.fetch).mockResolvedValue(mockResponse); 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(window.fetch).mockReturnValue( Promise.resolve( 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(window.fetch).mockReturnValue( Promise.resolve( 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(window.fetch).mockReturnValue( Promise.resolve( 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(window.fetch) .mockReturnValue( Promise.resolve( mockResponse(undefined, { url: "https://arbitrary.pod/container/" }) ) ); const solidDataset = await createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch, } ); expect(solidDataset.size).toBe(0); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve(new Response("Not allowed", { status: 403 })) ); const fetchPromise = createContainerAt("https://some.pod/container/", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( new Error( "Creating the empty Container at [https://some.pod/container/] failed: [403] [Forbidden]." ) ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn(window.fetch).mockReturnValue( Promise.resolve( new Response("I'm a teapot!", { 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!", }); }); describe("using the workaround for Node Solid Server", () => { it("creates and deletes a dummy file inside the Container when encountering NSS's exact error message", async () => { const mockFetch = jest .fn(window.fetch) // Mock the response to the request that tries to create a Container the regular way: .mockReturnValueOnce( Promise.resolve( new Response( internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465, { status: 409 } ) ) ) // Mock the response to the request that tests whether the Container already exists .mockReturnValueOnce( Promise.resolve(new Response("Not found", { status: 404 })) ) // Mock the response to the request that tries to create a dummy file: .mockReturnValueOnce( Promise.resolve(new Response("Creation successful.", { status: 200 })) ) // Mock the response to the request that then tries to delete that dummy file: .mockReturnValueOnce( Promise.resolve(new Response("Deletion successful", { status: 200 })) ) // Mock the response to the request that fetches the Container's metadata: .mockReturnValueOnce( Promise.resolve(new Response(undefined, { status: 200 })) ); await createContainerAt("https://arbitrary.pod/container/", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(5); expect(mockFetch.mock.calls[1][0]).toBe( "https://arbitrary.pod/container/" ); expect(mockFetch.mock.calls[1][1]?.method).toBe("HEAD"); expect(mockFetch.mock.calls[2][0]).toBe( "https://arbitrary.pod/container/.dummy" ); expect(mockFetch.mock.calls[2][1]?.method).toBe("PUT"); expect(mockFetch.mock.calls[3][0]).toBe( "https://arbitrary.pod/container/.dummy" ); expect(mockFetch.mock.calls[3][1]?.method).toBe("DELETE"); expect(mockFetch.mock.calls[4][0]).toBe( "https://arbitrary.pod/container/" ); expect(mockFetch.mock.calls[4][1]?.method).toBe("HEAD"); }); it("does not attempt to create a dummy file on a regular 409 error", async () => { const mockFetch = jest .fn(window.fetch) .mockReturnValue( Promise.resolve( new Response( "This is a perfectly regular 409 error that has nothing to do with our not supporting the spec.", { status: 409 } ) ) ); const fetchPromise = createContainerAt( "https://arbitrary.pod/container/", { fetch: mockFetch, } ); await expect(fetchPromise).rejects.toThrow( new Error( "Creating the empty Container at [https://arbitrary.pod/container/] failed: [409] [Conflict]." ) ); expect(mockFetch.mock.calls).toHaveLength(1); }); it("appends a trailing slash if not provided", async () => { const mockFetch = jest .fn(window.fetch) // Mock the response to the request that tries to create a Container the regular way: .mockReturnValueOnce( Promise.resolve( new Response( internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465, { status: 409 } ) ) ) // Mock the response to the request that tests whether the Container already exists .mo