@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
1,582 lines (1,392 loc) • 106 kB
text/typescript
// 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" }),
);