@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
1,143 lines (989 loc) • 36.2 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, it, expect } from "@jest/globals";
import { DataFactory } from "n3";
import { dataset } from "@rdfjs/dataset";
import {
getThing,
getThingAll,
setThing,
removeThing,
createThing,
asUrl,
isThing,
thingAsMarkdown,
ThingExpectedError,
ValidThingUrlExpectedError,
ValidPropertyUrlExpectedError,
ValidValueUrlExpectedError,
} from "./thing";
import { internal_throwIfNotThing } from "./thing.internal";
import type {
Thing,
ThingLocal,
ThingPersisted,
SolidDataset,
WithServerResourceInfo,
} from "../interfaces";
import { SolidClientError } from "../interfaces";
import { createSolidDataset } from "../resource/solidDataset";
import { mockThingFrom } from "./mock";
import {
addStringNoLocale,
addInteger,
addStringWithLocale,
addIri,
addBoolean,
addDatetime,
addDecimal,
} from "./add";
import type { WithAcl } from "../acl/acl";
import { mockSolidDatasetFrom } from "../resource/mock";
import { internal_setAcl } from "../acl/acl.internal";
import type { BlankNodeId, LocalNodeIri } from "../rdf.internal";
import { localNodeSkolemPrefix } from "../rdf.internal";
describe("createThing", () => {
it("automatically generates a unique name for the Thing", () => {
const thing1: ThingLocal = createThing();
const thing2: ThingLocal = createThing();
expect(typeof thing1.url).toBe("string");
expect(thing1.url.length).toBeGreaterThan(localNodeSkolemPrefix.length);
expect(thing1.url).not.toBe(thing2.url);
});
it("uses the given name, if any", () => {
const thing: ThingLocal = createThing({ name: "some-name" });
expect(thing.url).toBe(`${localNodeSkolemPrefix}some-name`);
});
it("uses the given IRI, if any", () => {
const thing: ThingPersisted = createThing({
url: "https://some.pod/resource#thing",
});
expect(thing.url).toBe("https://some.pod/resource#thing");
});
it("throws an error if the given URL is invalid", () => {
expect(() => createThing({ url: "Invalid IRI" })).toThrow();
});
});
describe("isThing", () => {
it("returns true for a ThingLocal", () => {
expect(isThing(createThing())).toBe(true);
});
it("returns true for a ThingPersisted", () => {
expect(isThing(mockThingFrom("https://arbitrary.pod/resource#thing"))).toBe(
true,
);
});
it("returns false for an atomic data type", () => {
expect(isThing("This is not a Thing")).toBe(false);
});
it("returns false for a regular JavaScript object", () => {
expect(isThing({ not: "a Thing" })).toBe(false);
});
it("returns false for a plain RDF/JS Dataset", () => {
expect(isThing(dataset())).toBe(false);
});
it("returns false for a SolidDataset", () => {
expect(isThing(createSolidDataset())).toBe(false);
});
});
describe("getThing", () => {
const mockThing1Iri = "https://some.pod/resource#subject1";
const mockThing2Iri = "https://some.pod/resource#subject2";
const otherGraphIri = "https://some.vocab/graph";
const mockThing1: ThingPersisted = {
type: "Subject",
url: mockThing1Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockThing2: ThingPersisted = {
type: "Subject",
url: mockThing2Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockLocalThingIri =
`${localNodeSkolemPrefix}localSubject` as LocalNodeIri;
const mockLocalThing: ThingLocal = {
type: "Subject",
url: mockLocalThingIri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockBlankNodeThingId: BlankNodeId = "_:0";
const mockBlankNodeThing: Thing = {
type: "Subject",
url: mockBlankNodeThingId,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
function getMockDataset(
things = [mockThing1, mockThing2],
otherGraphThings = [mockThing1],
): SolidDataset {
const solidDataset: SolidDataset = {
type: "Dataset",
graphs: {
default: {},
[otherGraphIri]: {},
},
};
things.forEach((thing) => {
// The assertion allows writing to what we've declared to be a read-only property:
(solidDataset.graphs.default[thing.url] as any) = thing;
});
otherGraphThings.forEach((thing) => {
// The assertion allows writing to what we've declared to be a read-only property:
(solidDataset.graphs[otherGraphIri][thing.url] as any) = thing;
});
return solidDataset;
}
it("returns a Dataset with just Quads in there with the given Subject", () => {
const thing = getThing(getMockDataset(), mockThing1Iri);
expect(thing).toStrictEqual(mockThing1);
});
it("accepts a Named Node as the Subject identifier", () => {
const thing = getThing(
getMockDataset(),
DataFactory.namedNode(mockThing1Iri),
);
expect(thing).toStrictEqual(mockThing1);
});
it("accepts a LocalNode as the Subject identifier", () => {
const thing = getThing(
getMockDataset([mockLocalThing]),
DataFactory.namedNode(mockLocalThingIri),
);
expect(thing).toStrictEqual(mockLocalThing);
});
it("returns null if the given SolidDataset does not include Quads with the given Subject", () => {
const thing = getThing(
getMockDataset([]),
"https://arbitrary.vocab/subject",
);
expect(thing).toBeNull();
});
it("accepts a LocalNode as the Subject identifier even for Things with resolved IRIs", () => {
const mockDataset = getMockDataset([mockThing1]);
const mockDatasetWithResourceInfo: SolidDataset & WithServerResourceInfo = {
...mockDataset,
internal_resourceInfo: {
isRawData: false,
linkedResources: {},
sourceIri: mockThing1Iri.substring(
0,
mockThing1Iri.length - "subject1".length,
),
},
};
const thing = getThing(
mockDatasetWithResourceInfo,
`${localNodeSkolemPrefix}subject1`,
);
expect(thing).toStrictEqual(mockThing1);
});
it("only returns Quads from the default graph if no scope was specified", () => {
expect(
getThing(getMockDataset([mockThing1], [mockThing2]), mockThing2Iri),
).toBeNull();
expect(
getThing(getMockDataset([mockThing1], [mockThing2]), mockThing1Iri),
).toStrictEqual(mockThing1);
});
it("is able to limit the Thing's scope to a single Named Graph", () => {
expect(
getThing(getMockDataset([mockThing1], [mockThing2]), mockThing2Iri, {
scope: otherGraphIri,
}),
).toStrictEqual(mockThing2);
});
it("is able to specify the scope using a Named Node", () => {
expect(
getThing(getMockDataset([mockThing1], [mockThing2]), mockThing2Iri, {
scope: DataFactory.namedNode(otherGraphIri),
}),
).toStrictEqual(mockThing2);
});
it("returns null if the given scope does not include the requested Thing", () => {
expect(
getThing(getMockDataset([mockThing1], [mockThing2]), mockThing1Iri, {
scope: otherGraphIri,
}),
).toBeNull();
});
it("returns null if the given scope does not include any Things", () => {
expect(
getThing(getMockDataset([mockThing1], [mockThing2]), mockThing2Iri, {
scope: "https://arbitrary.vocab/other-graph",
}),
).toBeNull();
});
it("throws an error when given an invalid URL", () => {
expect(() => getThing(getMockDataset(), "not-a-url")).toThrow(
"Expected a valid URL to identify a Thing, but received: [not-a-url].",
);
});
it("throws an instance of ThingUrlExpectedError on invalid URLs", () => {
let thrownError;
try {
getThing(getMockDataset(), "not-a-url");
} catch (e) {
thrownError = e;
}
expect(thrownError).toBeInstanceOf(ValidThingUrlExpectedError);
});
it("accepts blank nodes identifiers", () => {
const thing = getThing(
getMockDataset([mockBlankNodeThing]),
mockBlankNodeThingId,
);
expect(thing).toStrictEqual(mockBlankNodeThing);
});
});
describe("getThingAll", () => {
const mockThing1Iri = "https://some.vocab/subject1";
const mockThing2Iri = "https://some.vocab/subject2";
const otherGraphIri = "https://some.vocab/graph";
const mockThing1: ThingPersisted = {
type: "Subject",
url: mockThing1Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockThing2: ThingPersisted = {
type: "Subject",
url: mockThing2Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
function getMockDataset(
things = [mockThing1, mockThing2],
otherGraphThings = [mockThing1],
): SolidDataset {
const solidDataset: SolidDataset = {
type: "Dataset",
graphs: {
default: {},
[otherGraphIri]: {},
},
};
things.forEach((thing) => {
// The assertion allows writing to what we've declared to be a read-only property:
(solidDataset.graphs.default[thing.url] as any) = thing;
});
otherGraphThings.forEach((thing) => {
// The assertion allows writing to what we've declared to be a read-only property:
(solidDataset.graphs[otherGraphIri][thing.url] as any) = thing;
});
return solidDataset;
}
it("returns the individual Things", () => {
const things = getThingAll(getMockDataset([mockThing1, mockThing2]));
expect(things).toStrictEqual([mockThing1, mockThing2]);
});
it("does not return Things with a Blank Node as the Subject by default", () => {
const mockDataset = getMockDataset([mockThing1]);
const blankNode = {
predicates: {
"https://arbitrary.predicate": {
namedNodes: ["https://arbitrary.value"],
},
},
type: "Subject",
url: "_:blankNodeId",
};
(mockDataset.graphs.default["_:blankNodeId"] as any) = blankNode;
const things = getThingAll(mockDataset);
expect(things).toHaveLength(1);
expect(things).toStrictEqual([mockThing1]);
});
it("returns Things with a Blank Node as the Subject if specified", () => {
const mockDataset = getMockDataset([mockThing1]);
const blankNode = {
predicates: {
"https://arbitrary.predicate": {
namedNodes: ["https://arbitrary.value"],
},
},
type: "Subject",
url: "_:blankNodeId",
};
(mockDataset.graphs.default["_:blankNodeId"] as any) = blankNode;
const things = getThingAll(mockDataset, { acceptBlankNodes: true });
expect(things).toHaveLength(2);
expect(things).toStrictEqual([mockThing1, blankNode]);
});
it("returns Quads from the default Graphs if no scope was specified", () => {
const things = getThingAll(getMockDataset([mockThing1], [mockThing2]));
expect(things).toStrictEqual([mockThing1]);
});
it("ignores Quads in the default graph when specifying an explicit scope", () => {
const things = getThingAll(getMockDataset([mockThing1], [mockThing2]), {
scope: otherGraphIri,
});
expect(things).toStrictEqual([mockThing2]);
});
it("is able to specify the scope using a Named Node", () => {
const things = getThingAll(getMockDataset([mockThing1], [mockThing2]), {
scope: DataFactory.namedNode(otherGraphIri),
});
expect(things).toStrictEqual([mockThing2]);
});
it("returns an empty array if the given scope does not include any Things", () => {
const things = getThingAll(getMockDataset([mockThing1], [mockThing2]), {
scope: "https://arbitrary.vocab/other-graph",
});
expect(things).toStrictEqual([]);
});
});
describe("setThing", () => {
const mockThing1Iri = "https://some.vocab/subject1";
const mockThing2Iri = "https://some.vocab/subject2";
const mockThing1: ThingPersisted = {
type: "Subject",
url: mockThing1Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockThing2: ThingPersisted = {
type: "Subject",
url: mockThing2Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
function getMockDataset(things = [mockThing1, mockThing2]): SolidDataset {
const solidDataset: SolidDataset = {
type: "Dataset",
graphs: {
default: {},
},
};
things.forEach((thing) => {
// The assertion allows writing to what we've declared to be a read-only property:
(solidDataset.graphs.default[thing.url] as any) = thing;
});
return solidDataset;
}
it("returns a Dataset with the new Thing added to it", () => {
const datasetWithExistingThings = getMockDataset([mockThing1]);
const updatedDataset = setThing(datasetWithExistingThings, mockThing2);
expect(updatedDataset.graphs).toStrictEqual(
getMockDataset([mockThing1, mockThing2]).graphs,
);
});
it("keeps track of additions and deletions in the attached change log", () => {
const datasetWithExistingThings = getMockDataset([mockThing1]);
const updatedDataset = setThing(datasetWithExistingThings, mockThing2);
expect(updatedDataset.internal_changeLog.additions).toHaveLength(1);
expect(updatedDataset.internal_changeLog.deletions).toHaveLength(0);
expect(updatedDataset.internal_changeLog.additions[0].subject.value).toBe(
mockThing2Iri,
);
});
it("reconciles deletions and additions in the change log", () => {
const datasetWithExistingThings = getMockDataset([mockThing1, mockThing2]);
const datasetWithThing2Removed = removeThing(
datasetWithExistingThings,
mockThing2,
);
expect(datasetWithThing2Removed.internal_changeLog.additions).toHaveLength(
0,
);
expect(datasetWithThing2Removed.internal_changeLog.deletions).toHaveLength(
1,
);
const datasetWithThing2AddedAgain = setThing(
datasetWithThing2Removed,
mockThing2,
);
expect(
datasetWithThing2AddedAgain.internal_changeLog.additions,
).toHaveLength(0);
expect(
datasetWithThing2AddedAgain.internal_changeLog.deletions,
).toHaveLength(0);
});
it("preserves existing change logs", () => {
const datasetWithoutThings = getMockDataset([]);
const datasetWithThing1Added = setThing(datasetWithoutThings, mockThing1);
expect(datasetWithThing1Added.internal_changeLog.additions).toHaveLength(1);
expect(datasetWithThing1Added.internal_changeLog.deletions).toHaveLength(0);
const datasetWithThing2AddedToo = setThing(
datasetWithThing1Added,
mockThing2,
);
expect(datasetWithThing2AddedToo.internal_changeLog.additions).toHaveLength(
2,
);
expect(datasetWithThing2AddedToo.internal_changeLog.deletions).toHaveLength(
0,
);
});
it("does not modify the original SolidDataset", () => {
const datasetWithExistingThings = getMockDataset([mockThing1]);
const updatedDataset = setThing(datasetWithExistingThings, mockThing2);
expect(updatedDataset).not.toStrictEqual(datasetWithExistingThings);
});
it("can reconcile new LocalNodes with existing NamedNodes if the SolidDataset has a resource IRI attached", () => {
let solidDataset = mockSolidDatasetFrom("https://some.pod/resource");
const originalThing: ThingPersisted = {
type: "Subject",
url: "https://some.pod/resource#subjectName",
predicates: {
"https://arbitrary.predicate": {
namedNodes: ["https://arbitrary.value"],
},
},
};
solidDataset = setThing(solidDataset, originalThing);
const updatedThing: ThingLocal = {
type: "Subject",
url: `${localNodeSkolemPrefix}subjectName` as LocalNodeIri,
predicates: {
"https://some.predicate": {
namedNodes: ["https://some.value"],
},
},
};
const updatedDataset = setThing(solidDataset, updatedThing);
expect(
getThing(updatedDataset, "https://some.pod/resource#subjectName")
?.predicates,
).toStrictEqual(updatedThing.predicates);
});
it("only updates LocalNodes if the SolidDataset has no known IRI", () => {
let solidDataset = createSolidDataset();
const originalThing: ThingPersisted = {
type: "Subject",
url: "https://some.pod/resource#subjectName",
predicates: {
"https://arbitrary.predicate": {
namedNodes: ["https://arbitrary.value"],
},
},
};
solidDataset = setThing(solidDataset, originalThing);
const updatedThing: ThingLocal = {
type: "Subject",
url: `${localNodeSkolemPrefix}subjectName` as LocalNodeIri,
predicates: {
"https://some.predicate": {
namedNodes: ["https://some.value"],
},
},
};
const updatedDataset = setThing(solidDataset, updatedThing);
expect(
getThing(updatedDataset, "https://some.pod/resource#subjectName"),
).toStrictEqual(originalThing);
});
});
describe("removeThing", () => {
const mockThing1Iri = "https://some.vocab/subject1";
const mockThing2Iri = "https://some.vocab/subject2";
const mockThing1: ThingPersisted = {
type: "Subject",
url: mockThing1Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockThing2: ThingPersisted = {
type: "Subject",
url: mockThing2Iri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
const mockLocalThingIri =
`${localNodeSkolemPrefix}localSubject` as LocalNodeIri;
const mockLocalThing: ThingLocal = {
type: "Subject",
url: mockLocalThingIri,
predicates: {
"https://arbitrary.vocab/predicate": {
namedNodes: ["https://arbitrary.vocab/predicate"],
},
},
};
function getMockDataset(things = [mockThing1, mockThing2]): SolidDataset {
const solidDataset: SolidDataset = {
type: "Dataset",
graphs: {
default: {},
},
};
things.forEach((thing) => {
// The assertion allows writing to what we've declared to be a read-only property:
(solidDataset.graphs.default[thing.url] as any) = thing;
});
return solidDataset;
}
it("returns a Dataset that excludes Quads with the Thing's Subject", () => {
const datasetWithMultipleThings = getMockDataset([mockThing1, mockThing2]);
const updatedDataset = removeThing(datasetWithMultipleThings, mockThing2);
expect(updatedDataset.graphs).toStrictEqual(
getMockDataset([mockThing1]).graphs,
);
});
it("keeps track of deletions in the attached change log", () => {
const datasetWithExistingThings = getMockDataset([mockThing1, mockThing2]);
const updatedDataset = removeThing(datasetWithExistingThings, mockThing2);
expect(updatedDataset.internal_changeLog.additions).toHaveLength(0);
expect(updatedDataset.internal_changeLog.deletions).toHaveLength(1);
expect(updatedDataset.internal_changeLog.deletions[0].subject.value).toBe(
mockThing2Iri,
);
});
it("reconciles deletions in the change log with additions", () => {
const datasetWithExistingThings = getMockDataset([mockThing1]);
const datasetWithThing2Added = setThing(
datasetWithExistingThings,
mockThing2,
);
expect(datasetWithThing2Added.internal_changeLog.additions).toHaveLength(1);
expect(datasetWithThing2Added.internal_changeLog.deletions).toHaveLength(0);
const datasetWithThing2RemovedAgain = removeThing(
datasetWithExistingThings,
mockThing2,
);
expect(
datasetWithThing2RemovedAgain.internal_changeLog.additions,
).toHaveLength(0);
expect(
datasetWithThing2RemovedAgain.internal_changeLog.deletions,
).toHaveLength(0);
});
it("preserves existing change logs", () => {
const datasetWithoutThings = getMockDataset([mockThing2]);
const datasetWithThing1Added = setThing(datasetWithoutThings, mockThing1);
expect(datasetWithThing1Added.internal_changeLog.additions).toHaveLength(1);
expect(datasetWithThing1Added.internal_changeLog.deletions).toHaveLength(0);
const datasetWithThing2AddedToo = removeThing(
datasetWithThing1Added,
mockThing2,
);
expect(datasetWithThing2AddedToo.internal_changeLog.additions).toHaveLength(
1,
);
expect(datasetWithThing2AddedToo.internal_changeLog.deletions).toHaveLength(
1,
);
});
it("preserves attached ACLs", () => {
const datasetWithFetchedAcls: SolidDataset & WithAcl = internal_setAcl(
mockSolidDatasetFrom("https://some.vocab/"),
{
resourceAcl: null,
fallbackAcl: null,
},
);
// The assertion is to tell the type system we can write to this:
(datasetWithFetchedAcls.graphs.default[mockThing1Iri] as Thing) =
mockThing1;
const updatedDataset = removeThing(datasetWithFetchedAcls, mockThing1);
expect(updatedDataset.internal_acl).toEqual({
resourceAcl: null,
fallbackAcl: null,
});
});
it("returns a Dataset that excludes Quads with a given Subject IRI", () => {
const datasetWithMultipleThings = getMockDataset([mockThing1, mockThing2]);
const updatedDataset = removeThing(
datasetWithMultipleThings,
mockThing2Iri,
);
expect(updatedDataset.graphs).toStrictEqual(
getMockDataset([mockThing1]).graphs,
);
});
it("does not modify the original SolidDataset", () => {
const datasetWithMultipleThings = getMockDataset([mockThing1, mockThing2]);
const updatedDataset = removeThing(
datasetWithMultipleThings,
mockThing2Iri,
);
expect(datasetWithMultipleThings.graphs).not.toStrictEqual(
updatedDataset.graphs,
);
});
it("returns a Dataset that excludes Quads with a given NamedNode as their Subject", () => {
const datasetWithMultipleThings = getMockDataset([mockThing1, mockThing2]);
const updatedDataset = removeThing(
datasetWithMultipleThings,
DataFactory.namedNode(mockThing2Iri),
);
expect(updatedDataset.graphs).toStrictEqual(
getMockDataset([mockThing1]).graphs,
);
});
it("can recognise LocalNodes", () => {
const solidDataset = getMockDataset([mockLocalThing]);
const updatedDataset = removeThing(solidDataset, mockLocalThingIri);
expect(getThingAll(updatedDataset)).toHaveLength(0);
});
it("can reconcile given LocalNodes with existing NamedNodes if the SolidDataset has a resource IRI attached", () => {
let solidDataset = mockSolidDatasetFrom("https://some.pod/resource");
const thingWithFullIri: ThingPersisted = {
type: "Subject",
url: "https://some.pod/resource#subjectName",
predicates: {
"https://arbitrary.predicate": {
namedNodes: ["https://arbitrary.value"],
},
},
};
solidDataset = setThing(solidDataset, thingWithFullIri);
const updatedDataset = removeThing(
solidDataset,
`${localNodeSkolemPrefix}subjectName`,
);
expect(getThingAll(updatedDataset)).toHaveLength(0);
});
it("only removes LocalNodes if the SolidDataset has no known IRI", () => {
let solidDataset = createSolidDataset();
const resolvedThing: ThingPersisted = {
type: "Subject",
url: "https://some.pod/resource#subjectName",
predicates: {
"https://arbitrary.predicate": {
namedNodes: ["https://arbitrary.value"],
},
},
};
const localThing: ThingLocal = {
type: "Subject",
url: `${localNodeSkolemPrefix}subjectName` as LocalNodeIri,
predicates: {
"https://some.predicate": {
namedNodes: ["https://some.value"],
},
},
};
solidDataset = setThing(solidDataset, resolvedThing);
solidDataset = setThing(solidDataset, localThing);
const updatedDataset = removeThing(solidDataset, localThing);
expect(
getThing(updatedDataset, "https://some.pod/resource#subjectName"),
).toStrictEqual(resolvedThing);
});
});
describe("asIri", () => {
it("returns the IRI of a persisted Thing", () => {
const persistedThing = mockThingFrom("https://some.pod/resource#thing");
expect(asUrl(persistedThing)).toBe("https://some.pod/resource#thing");
});
it("returns the IRI of a local Thing relative to a given base IRI", () => {
const localThing: ThingLocal = {
type: "Subject",
predicates: {},
url: `${localNodeSkolemPrefix}some-name` as LocalNodeIri,
};
expect(asUrl(localThing, "https://some.pod/resource")).toBe(
"https://some.pod/resource#some-name",
);
});
it("accepts a Thing of which it is not known whether it is persisted yet", () => {
const thing = mockThingFrom("https://some.pod/resource#thing");
expect(asUrl(thing as Thing, "https://arbitrary.url")).toBe(
"https://some.pod/resource#thing",
);
});
it("triggers a TypeScript error when passed a ThingLocal without a base IRI", () => {
const localThing: ThingLocal = createThing();
// @ts-expect-error This is the entire point of this unit test:
expect(() => asUrl(localThing)).toThrow();
});
// This currently fails because a plain `Thing` always has a `url` property that is a string,
// and is therefore indistinguishable from a `ThingPersisted`. Not sure what the solution is yet.
// Meanwhile TS users won't get a build-time error if they're passing a plain `Thing`,
// which is annoying but not a major issue.
it.skip("triggers a TypeScript error when passed a Thing without a base IRI", () => {
const plainThing = createThing() as Thing;
// @ts-expect<disabled because it does not work yet>-error
expect(() => asUrl(plainThing)).toThrow();
});
it("does not trigger a TypeScript error when passed a ThingPersisted without a base IRI", () => {
// We're only checking for the absence TypeScript errors:
expect.assertions(0);
const resolvedThing: ThingPersisted = mockThingFrom(
"https://some.pod/resource#thing",
);
// This should not error:
asUrl(resolvedThing);
});
it("throws an error when a local Thing was given without a base IRI", () => {
const localThing: ThingLocal = {
type: "Subject",
predicates: {},
url: `${localNodeSkolemPrefix}some-name` as LocalNodeIri,
};
expect(() => asUrl(localThing, undefined as any)).toThrow(
"The URL of a Thing that has not been persisted cannot be determined without a base URL.",
);
});
});
describe("thingAsMarkdown", () => {
it("returns a readable version of an empty, unsaved Thing", () => {
const emptyThing = createThing({ name: "empty-thing" });
expect(thingAsMarkdown(emptyThing)).toBe(
"## Thing (no URL yet — identifier: `#empty-thing`)\n\n<empty>\n",
);
});
it("returns a readable version of an empty Thing with a known URL", () => {
const emptyThing = mockThingFrom("https://some.pod/resource#thing");
expect(thingAsMarkdown(emptyThing)).toBe(
"## Thing: https://some.pod/resource#thing\n\n<empty>\n",
);
});
it("returns a readable version of a Thing with just one property", () => {
let thingWithValue = createThing({ name: "with-one-value" });
thingWithValue = addStringNoLocale(
thingWithValue,
"https://some.vocab/predicate",
"Some value",
);
expect(thingAsMarkdown(thingWithValue)).toBe(
"## Thing (no URL yet — identifier: `#with-one-value`)\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
'- "Some value" (string)\n',
);
});
it("returns a readable version of a Thing with multiple properties and values", () => {
const mockThingObject = createThing({ name: "local-node-object" });
let thingWithValues = createThing({ name: "with-values" });
thingWithValues = addStringWithLocale(
thingWithValues,
"https://some.vocab/predicate",
"Some value",
"en-gb",
);
thingWithValues = addStringNoLocale(
thingWithValues,
"https://some.vocab/predicate",
"Some other value",
);
thingWithValues = addBoolean(
thingWithValues,
"https://some.vocab/predicate",
true,
);
thingWithValues = addDatetime(
thingWithValues,
"https://some.vocab/predicate",
new Date(Date.UTC(1990, 10, 12, 13, 37, 42, 0)),
);
thingWithValues = addDecimal(
thingWithValues,
"https://some.vocab/predicate",
13.37,
);
thingWithValues = addInteger(
thingWithValues,
"https://some.vocab/predicate",
42,
);
thingWithValues = addIri(
thingWithValues,
"https://some.vocab/other-predicate",
"https://some.url",
);
thingWithValues = addIri(
thingWithValues,
"https://some.vocab/other-predicate",
mockThingObject,
);
expect(thingAsMarkdown(thingWithValues)).toBe(
"## Thing (no URL yet — identifier: `#with-values`)\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
'- "Some value" (en-gb string)\n' +
'- "Some other value" (string)\n' +
"- true (boolean)\n" +
"- Mon, 12 Nov 1990 13:37:42 GMT (datetime)\n" +
"- 13.37 (decimal)\n" +
"- 42 (integer)\n" +
"\n" +
"Property: https://some.vocab/other-predicate\n" +
"- <https://some.url> (URL)\n" +
"- <#local-node-object> (URL)\n",
);
});
it("returns a readable version of a Thing that points to other Things", () => {
let thing1 = createThing({ name: "thing1" });
const thing2 = mockThingFrom("https://some.pod/resource#thing2");
const thing3 = createThing({ name: "thing3" });
thing1 = addIri(thing1, "https://some.vocab/predicate", thing2);
thing1 = addIri(thing1, "https://some.vocab/predicate", thing3);
expect(thingAsMarkdown(thing1)).toBe(
"## Thing (no URL yet — identifier: `#thing1`)\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
"- <https://some.pod/resource#thing2> (URL)\n" +
"- <#thing3> (URL)\n",
);
});
it("renders when values are invalid", () => {
const thing: Thing = {
type: "Subject",
url: "https://some.pod/resource#thing",
predicates: {
"https://some.vocab/predicate": {
blankNodes: ["_:some-blank-node"],
literals: {
"http://www.w3.org/2001/XMLSchema#boolean": ["not-a-boolean"],
"http://www.w3.org/2001/XMLSchema#dateTime": ["not-a-dateTime"],
"http://www.w3.org/2001/XMLSchema#decimal": ["not-a-decimal"],
"http://www.w3.org/2001/XMLSchema#integer": ["not-an-integer"],
"https://some.vocab/other-type": ["some other value"],
},
},
},
};
expect(thingAsMarkdown(thing)).toBe(
"## Thing: https://some.pod/resource#thing\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
"- Invalid data: `not-a-boolean` (boolean)\n" +
"- Invalid data: `not-a-dateTime` (datetime)\n" +
"- Invalid data: `not-a-decimal` (decimal)\n" +
"- Invalid data: `not-an-integer` (integer)\n" +
"- [some other value] (RDF/JS Literal of type: `https://some.vocab/other-type`)\n" +
"- [some-blank-node] (RDF/JS BlankNode)\n",
);
});
});
describe("throwIfNotThing", () => {
it("throws when passed null", () => {
expect(() => internal_throwIfNotThing(null as unknown as Thing)).toThrow(
"Expected a Thing, but received: [null].",
);
});
it("does not throw when passed a Thing", () => {
expect(() => internal_throwIfNotThing(createThing())).not.toThrow();
});
it("throws an instance of a SolidClientError", () => {
let error;
try {
internal_throwIfNotThing(null as unknown as Thing);
} catch (e: unknown) {
error = e;
}
expect(error).toBeInstanceOf(SolidClientError);
});
it("throws an instance of a ThingExpectedError", () => {
let error;
try {
internal_throwIfNotThing(null as unknown as Thing);
} catch (e: unknown) {
error = e;
}
expect(error).toBeInstanceOf(ThingExpectedError);
});
});
describe("ValidPropertyUrlExpectedError", () => {
it("logs the invalid property in its error message", () => {
const error = new ValidPropertyUrlExpectedError(null);
expect(error.message).toBe(
"Expected a valid URL to identify a property, but received: [null].",
);
});
it("logs the value of an invalid URL inside a Named Node in its error message", () => {
const error = new ValidPropertyUrlExpectedError(
DataFactory.namedNode("not-a-url"),
);
expect(error.message).toBe(
"Expected a valid URL to identify a property, but received: [not-a-url].",
);
});
it("exposes the invalid property", () => {
const error = new ValidPropertyUrlExpectedError({ not: "a-url" });
expect(error.receivedProperty).toEqual({ not: "a-url" });
});
});
describe("ValidValueUrlExpectedError", () => {
it("logs the invalid property in its error message", () => {
const error = new ValidValueUrlExpectedError(null);
expect(error.message).toBe(
"Expected a valid URL value, but received: [null].",
);
});
it("logs the value of an invalid URL inside a Named Node in its error message", () => {
const error = new ValidValueUrlExpectedError(
DataFactory.namedNode("not-a-url"),
);
expect(error.message).toBe(
"Expected a valid URL value, but received: [not-a-url].",
);
});
it("exposes the invalid property", () => {
const error = new ValidValueUrlExpectedError({ not: "a-url" });
expect(error.receivedValue).toEqual({ not: "a-url" });
});
});
describe("ValidThingUrlExpectedError", () => {
it("logs the invalid property in its error message", () => {
const error = new ValidThingUrlExpectedError(null);
expect(error.message).toBe(
"Expected a valid URL to identify a Thing, but received: [null].",
);
});
it("logs the value of an invalid URL inside a Named Node in its error message", () => {
const error = new ValidThingUrlExpectedError(
DataFactory.namedNode("not-a-url"),
);
expect(error.message).toBe(
"Expected a valid URL to identify a Thing, but received: [not-a-url].",
);
});
it("exposes the invalid property", () => {
const error = new ValidThingUrlExpectedError({ not: "a-url" });
expect(error.receivedValue).toEqual({ not: "a-url" });
});
});