UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

1,143 lines (989 loc) 36.2 kB
// Copyright Inrupt Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the // Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import { describe, 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" }); }); });