@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
1,628 lines (1,420 loc) • 90.8 kB
text/typescript
/**
* 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