@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
1,505 lines (1,322 loc) • 53.4 kB
text/typescript
/**
* Copyright 2020 Inrupt Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { describe, it, expect } from "@jest/globals";
import { Literal, NamedNode, Quad_Object } from "rdf-js";
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_getReadableValue,
internal_throwIfNotThing,
internal_toNode,
} from "./thing.internal";
import {
IriString,
Thing,
ThingLocal,
ThingPersisted,
SolidDataset,
WithResourceInfo,
WithChangeLog,
LocalNode,
SolidClientError,
} from "../interfaces";
import { createSolidDataset } from "../resource/solidDataset";
import { mockThingFrom } from "./mock";
import {
addStringNoLocale,
addInteger,
addStringWithLocale,
addIri,
addBoolean,
addDatetime,
addDecimal,
} from "./add";
import { AclDataset, WithAcl } from "../acl/acl";
import { mockSolidDatasetFrom } from "../resource/mock";
import { internal_setAcl } from "../acl/acl.internal";
function getMockQuad(
terms: Partial<{
subject: IriString;
predicate: IriString;
object: IriString;
namedGraph: IriString;
}> = {}
) {
const subject: NamedNode = DataFactory.namedNode(
terms.subject ?? "https://arbitrary.vocab/subject"
);
const predicate: NamedNode = DataFactory.namedNode(
terms.predicate ?? "https://arbitrary.vocab/predicate"
);
const object: NamedNode = DataFactory.namedNode(
terms.object ?? "https://arbitrary.vocab/object"
);
const namedGraph: NamedNode | undefined = terms.namedGraph
? DataFactory.namedNode(terms.namedGraph)
: undefined;
return DataFactory.quad(subject, predicate, object, namedGraph);
}
describe("createThing", () => {
it("automatically generates a unique name for the Thing", () => {
const thing1: ThingLocal = createThing();
const thing2: ThingLocal = createThing();
expect(typeof thing1.internal_localSubject.internal_name).toBe("string");
expect(thing1.internal_localSubject.internal_name.length).toBeGreaterThan(
0
);
expect(thing1.internal_localSubject.internal_name).not.toEqual(
thing2.internal_localSubject.internal_name
);
});
it("uses the given name, if any", () => {
const thing: ThingLocal = createThing({ name: "some-name" });
expect(thing.internal_localSubject.internal_name).toBe("some-name");
});
it("uses the given IRI, if any", () => {
const thing: ThingPersisted = createThing({
url: "https://some.pod/resource#thing",
});
expect(thing.internal_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", () => {
it("returns a Dataset with just Quads in there with the given Subject", () => {
const relevantQuad = getMockQuad({ subject: "https://some.vocab/subject" });
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(relevantQuad);
datasetWithMultipleThings.add(
getMockQuad({ subject: "https://arbitrary-other.vocab/subject" })
);
const thing = getThing(
datasetWithMultipleThings,
"https://some.vocab/subject"
) as Thing;
expect(Array.from(thing)).toEqual([relevantQuad]);
});
it("accepts a Named Node as the Subject identifier", () => {
const relevantQuad = getMockQuad({ subject: "https://some.vocab/subject" });
const datasetWithAThing = dataset();
datasetWithAThing.add(relevantQuad);
const thing = getThing(
datasetWithAThing,
DataFactory.namedNode("https://some.vocab/subject")
) as Thing;
expect(Array.from(thing)).toEqual([relevantQuad]);
});
it("accepts a LocalNode as the Subject identifier", () => {
const quadWithLocalSubject = getMockQuad();
const localSubject = Object.assign(
DataFactory.blankNode("Arbitrary blank node"),
{ internal_name: "localSubject" }
);
quadWithLocalSubject.subject = localSubject;
const datasetWithThingLocal = dataset();
datasetWithThingLocal.add(quadWithLocalSubject);
const thing = getThing(datasetWithThingLocal, localSubject) as Thing;
expect(Array.from(thing)).toEqual([quadWithLocalSubject]);
});
it("returns null if the given SolidDataset does not include Quads with the given Subject", () => {
const datasetWithoutTheThing = dataset();
datasetWithoutTheThing.add(
getMockQuad({ subject: "https://arbitrary-other.vocab/subject" })
);
const thing = getThing(
datasetWithoutTheThing,
"https://some.vocab/subject"
);
expect(thing).toBeNull();
});
it("returns Quads from all Named Graphs if no scope was specified", () => {
const quadInDefaultGraph = getMockQuad({
subject: "https://some.vocab/subject",
});
const quadInArbitraryGraph = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://arbitrary.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(quadInDefaultGraph);
datasetWithMultipleNamedGraphs.add(quadInArbitraryGraph);
const thing = getThing(
datasetWithMultipleNamedGraphs,
"https://some.vocab/subject"
) as Thing;
expect(thing.size).toBe(2);
expect(Array.from(thing)).toContain(quadInDefaultGraph);
expect(Array.from(thing)).toContain(quadInArbitraryGraph);
});
it("is able to limit the Thing's scope to a single Named Graph", () => {
const relevantQuad = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://some.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(relevantQuad);
datasetWithMultipleNamedGraphs.add(
getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://arbitrary-other.vocab/namedGraph",
})
);
const thing = getThing(
datasetWithMultipleNamedGraphs,
"https://some.vocab/subject",
{ scope: "https://some.vocab/namedGraph" }
) as Thing;
expect(Array.from(thing)).toEqual([relevantQuad]);
});
it("ignores Quads in the default graph when specifying an explicit scope", () => {
const relevantQuad = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://some.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(relevantQuad);
datasetWithMultipleNamedGraphs.add(
getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: undefined,
})
);
const thing = getThing(
datasetWithMultipleNamedGraphs,
"https://some.vocab/subject",
{ scope: "https://some.vocab/namedGraph" }
) as Thing;
expect(Array.from(thing)).toEqual([relevantQuad]);
});
it("is able to specify the scope using a Named Node", () => {
const relevantQuad = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://some.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(relevantQuad);
datasetWithMultipleNamedGraphs.add(
getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://arbitrary-other.vocab/namedGraph",
})
);
const thing = getThing(
datasetWithMultipleNamedGraphs,
"https://some.vocab/subject",
{ scope: DataFactory.namedNode("https://some.vocab/namedGraph") }
) as Thing;
expect(Array.from(thing)).toEqual([relevantQuad]);
});
it("throws an error when given an invalid URL", () => {
expect(() => getThing(dataset(), "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(dataset(), "not-a-url");
} catch (e) {
thrownError = e;
}
expect(thrownError).toBeInstanceOf(ValidThingUrlExpectedError);
});
});
describe("getThingAll", () => {
it("returns multiple Datasets, each with Quads with the same Subject", () => {
const thing1Quad = getMockQuad({ subject: "https://some.vocab/subject" });
const thing2Quad = getMockQuad({
subject: "https://some-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thing1Quad);
datasetWithMultipleThings.add(thing2Quad);
const things = getThingAll(datasetWithMultipleThings);
expect(Array.from(things[0])).toEqual([thing1Quad]);
expect(Array.from(things[1])).toEqual([thing2Quad]);
});
it("returns one Thing per unique Subject", () => {
const thing1Quad1 = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/object1",
});
const thing1Quad2 = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/object2",
});
const thing2Quad = getMockQuad({
subject: "https://some-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thing1Quad1);
datasetWithMultipleThings.add(thing1Quad2);
datasetWithMultipleThings.add(thing2Quad);
const things = getThingAll(datasetWithMultipleThings);
expect(things).toHaveLength(2);
expect(Array.from(things[0])).toEqual([thing1Quad1, thing1Quad2]);
expect(Array.from(things[1])).toEqual([thing2Quad]);
});
it("returns Quads from all Named Graphs if no scope was specified", () => {
const quadInDefaultGraph = getMockQuad({
subject: "https://some.vocab/subject",
});
const quadInArbitraryGraph = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://arbitrary.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(quadInDefaultGraph);
datasetWithMultipleNamedGraphs.add(quadInArbitraryGraph);
const things = getThingAll(datasetWithMultipleNamedGraphs);
expect(Array.from(things[0])).toContain(quadInDefaultGraph);
expect(Array.from(things[0])).toContain(quadInArbitraryGraph);
});
it("is able to limit the Things' scope to a single Named Graph", () => {
const relevantQuad = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://some.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(relevantQuad);
datasetWithMultipleNamedGraphs.add(
getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://arbitrary-other.vocab/namedGraph",
})
);
const things = getThingAll(datasetWithMultipleNamedGraphs, {
scope: "https://some.vocab/namedGraph",
});
expect(Array.from(things[0])).toEqual([relevantQuad]);
});
it("ignores Quads in the default graph when specifying an explicit scope", () => {
const relevantQuad = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://some.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(relevantQuad);
datasetWithMultipleNamedGraphs.add(
getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: undefined,
})
);
const things = getThingAll(datasetWithMultipleNamedGraphs, {
scope: "https://some.vocab/namedGraph",
});
expect(Array.from(things[0])).toEqual([relevantQuad]);
});
it("is able to specify the scope using a Named Node", () => {
const relevantQuad = getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://some.vocab/namedGraph",
});
const datasetWithMultipleNamedGraphs = dataset();
datasetWithMultipleNamedGraphs.add(relevantQuad);
datasetWithMultipleNamedGraphs.add(
getMockQuad({
subject: "https://some.vocab/subject",
namedGraph: "https://arbitrary-other.vocab/namedGraph",
})
);
const things = getThingAll(datasetWithMultipleNamedGraphs, {
scope: DataFactory.namedNode("https://some.vocab/namedGraph"),
});
expect(Array.from(things[0])).toEqual([relevantQuad]);
});
it("only returns Things whose Subject has an IRI, or is a LocalNode", () => {
const quadWithNamedSubject = getMockQuad({
subject: "https://some.vocab/subject",
});
const quadWithBlankSubject = getMockQuad();
quadWithBlankSubject.subject = DataFactory.blankNode(
"Arbitrary blank node"
);
const quadWithLocalSubject = getMockQuad();
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "localSubject" }
);
quadWithLocalSubject.subject = localSubject;
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(quadWithNamedSubject);
datasetWithMultipleThings.add(quadWithBlankSubject);
datasetWithMultipleThings.add(quadWithLocalSubject);
const things = getThingAll(datasetWithMultipleThings);
expect(things).toHaveLength(2);
expect(Array.from(things[0])).toEqual([quadWithNamedSubject]);
expect(Array.from(things[1])).toEqual([quadWithLocalSubject]);
});
});
describe("setThing", () => {
it("returns a Dataset with only the Thing's Quads having the Thing's Subject", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(oldThingQuad);
datasetWithMultipleThings.add(otherQuad);
const newThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithMultipleThings, newThing);
expect(Array.from(updatedDataset)).toEqual([otherQuad, newThingQuad]);
});
it("keeps track of additions and deletions in the attached change log", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(oldThingQuad);
datasetWithMultipleThings.add(otherQuad);
const newThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithMultipleThings, newThing);
expect(updatedDataset.internal_changeLog.additions).toEqual([newThingQuad]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([oldThingQuad]);
});
it("reconciles deletions and additions in the change log", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const addedQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/previously-added-object",
});
const deletedQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/previously-deleted-object",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(oldThingQuad);
datasetWithMultipleThings.add(otherQuad);
datasetWithMultipleThings.add(addedQuad);
const datasetWithExistingChangeLog = Object.assign(
datasetWithMultipleThings,
{
internal_changeLog: {
additions: [addedQuad],
deletions: [deletedQuad],
},
}
);
const newThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
newThing.add(newThingQuad);
newThing.add(deletedQuad);
const updatedDataset = setThing(datasetWithExistingChangeLog, newThing);
expect(updatedDataset.internal_changeLog.additions).toEqual([newThingQuad]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([oldThingQuad]);
});
it("does not add Quads with Blank Node objects to the ChangeLog", () => {
const blankNodeThingQuad = DataFactory.quad(
DataFactory.namedNode("https://some.vocab/subject"),
DataFactory.namedNode("https://arbitrary.vocab/predicate"),
DataFactory.blankNode()
);
const datasetWithBlankNodeThing = dataset();
datasetWithBlankNodeThing.add(blankNodeThingQuad);
const equivalentBlankNodeThingQuad = DataFactory.quad(
DataFactory.namedNode("https://some.vocab/subject"),
DataFactory.namedNode("https://arbitrary.vocab/predicate"),
DataFactory.blankNode()
);
const newThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
newThing.add(equivalentBlankNodeThingQuad);
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithBlankNodeThing, newThing);
expect(updatedDataset.internal_changeLog.additions).toEqual([newThingQuad]);
// Specifically: deletions does not include blankNodeThingQuad:
expect(updatedDataset.internal_changeLog.deletions).toEqual([]);
});
it("preserves existing change logs", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const existingAddition = getMockQuad({
object: "https://some.vocab/addition-object",
});
const existingDeletion = getMockQuad({
object: "https://some.vocab/deletion-object",
});
const datasetWithExistingChangeLog: SolidDataset &
WithChangeLog = Object.assign(dataset(), {
internal_changeLog: {
additions: [existingAddition],
deletions: [existingDeletion],
},
});
datasetWithExistingChangeLog.add(oldThingQuad);
datasetWithExistingChangeLog.add(otherQuad);
const newThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithExistingChangeLog, newThing);
expect(updatedDataset.internal_changeLog.additions).toEqual([
existingAddition,
newThingQuad,
]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([
existingDeletion,
oldThingQuad,
]);
});
it("does not modify the original SolidDataset", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const existingAddition = getMockQuad({
object: "https://some.vocab/addition-object",
});
const existingDeletion = getMockQuad({
object: "https://some.vocab/deletion-object",
});
const datasetWithExistingChangeLog: SolidDataset &
WithChangeLog = Object.assign(dataset(), {
internal_changeLog: {
additions: [existingAddition],
deletions: [existingDeletion],
},
});
datasetWithExistingChangeLog.add(oldThingQuad);
datasetWithExistingChangeLog.add(otherQuad);
const newThingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
newThing.add(newThingQuad);
setThing(datasetWithExistingChangeLog, newThing);
expect(Array.from(datasetWithExistingChangeLog)).toEqual([
oldThingQuad,
otherQuad,
]);
expect(datasetWithExistingChangeLog.internal_changeLog.additions).toEqual([
existingAddition,
]);
expect(datasetWithExistingChangeLog.internal_changeLog.deletions).toEqual([
existingDeletion,
]);
});
it("does not modify Quads with unexpected Subjects", () => {
const unexpectedQuad = DataFactory.quad(
DataFactory.variable("Arbitrary unexpected Subject type"),
DataFactory.namedNode("https://arbitrary.vocab/predicate"),
DataFactory.namedNode("https://arbitrary.vocab/object")
);
const datasetWithUnexpectedQuad = dataset();
datasetWithUnexpectedQuad.add(unexpectedQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://arbitrary.vocab/subject",
});
const updatedDataset = setThing(datasetWithUnexpectedQuad, thing);
expect(Array.from(updatedDataset)).toEqual([unexpectedQuad]);
});
it("can recognise LocalNodes", () => {
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "localSubject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const oldThingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/old-object")
);
const datasetWithLocalSubject = dataset();
datasetWithLocalSubject.add(oldThingQuad);
const newThingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/new-object")
);
const newThing: Thing = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithLocalSubject, newThing);
expect(Array.from(updatedDataset)).toEqual([newThingQuad]);
});
it("can reconcile new LocalNodes with existing NamedNodes if the SolidDataset has a resource IRI attached", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.pod/resource#subject",
object: "https://some.vocab/old-object",
});
const datasetWithNamedNode: SolidDataset & WithResourceInfo = Object.assign(
dataset(),
{
internal_resourceInfo: {
sourceIri: "https://some.pod/resource",
isRawData: false,
linkedResources: {},
},
}
);
datasetWithNamedNode.add(oldThingQuad);
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "subject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const newThingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/new-object")
);
const newThing: Thing = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithNamedNode, newThing);
expect(Array.from(updatedDataset)).toEqual([newThingQuad]);
});
it("can reconcile new NamedNodes with existing LocalNodes if the SolidDataset has a resource IRI attached", () => {
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "subject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const oldThingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/new-object")
);
const datasetWithLocalSubject: SolidDataset &
WithResourceInfo = Object.assign(dataset(), {
internal_resourceInfo: {
sourceIri: "https://some.pod/resource",
isRawData: false,
linkedResources: {},
},
});
datasetWithLocalSubject.add(oldThingQuad);
const newThingQuad = getMockQuad({
subject: "https://some.pod/resource#subject",
object: "https://some.vocab/old-object",
});
const newThing: Thing = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithLocalSubject, newThing);
expect(Array.from(updatedDataset)).toEqual([newThingQuad]);
});
it("only updates LocalNodes if the SolidDataset has no known IRI", () => {
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "localSubject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const oldThingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/old-object")
);
const similarSubjectQuad = getMockQuad({
subject: "https://some.pod/resource#localSubject",
});
const datasetWithLocalSubject = dataset();
datasetWithLocalSubject.add(oldThingQuad);
datasetWithLocalSubject.add(similarSubjectQuad);
const newThingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/new-object")
);
const newThing: Thing = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
newThing.add(newThingQuad);
const updatedDataset = setThing(datasetWithLocalSubject, newThing);
expect(Array.from(updatedDataset)).toEqual([
similarSubjectQuad,
newThingQuad,
]);
});
});
describe("removeThing", () => {
it("returns a Dataset that excludes Quads with the Thing's Subject", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const sameSubjectQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(sameSubjectQuad);
datasetWithMultipleThings.add(otherQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
thing.add(thingQuad);
const updatedDataset = removeThing(datasetWithMultipleThings, thing);
expect(Array.from(updatedDataset)).toEqual([otherQuad]);
});
it("keeps track of deletions in the attached change log", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const sameSubjectQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(sameSubjectQuad);
datasetWithMultipleThings.add(otherQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
thing.add(thingQuad);
const updatedDataset = removeThing(datasetWithMultipleThings, thing);
expect(updatedDataset.internal_changeLog.additions).toEqual([]);
expect(updatedDataset.internal_changeLog.deletions).toHaveLength(2);
expect(updatedDataset.internal_changeLog.deletions).toContain(
sameSubjectQuad
);
expect(updatedDataset.internal_changeLog.deletions).toContain(thingQuad);
});
it("reconciles deletions in the change log with additions", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const sameSubjectQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/old-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(sameSubjectQuad);
datasetWithMultipleThings.add(otherQuad);
const datasetWithChangelog = Object.assign(datasetWithMultipleThings, {
internal_changeLog: {
additions: [thingQuad],
deletions: [],
},
});
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
thing.add(thingQuad);
const updatedDataset = removeThing(datasetWithChangelog, thing);
expect(updatedDataset.internal_changeLog.additions).toEqual([]);
expect(updatedDataset.internal_changeLog.deletions).toHaveLength(1);
expect(updatedDataset.internal_changeLog.deletions).toContain(
sameSubjectQuad
);
});
it("preserves existing change logs", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const existingAddition = getMockQuad({
object: "https://some.vocab/addition-object",
});
const existingDeletion = getMockQuad({
object: "https://some.vocab/deletion-object",
});
const datasetWithExistingChangeLog: SolidDataset &
WithChangeLog = Object.assign(dataset(), {
internal_changeLog: {
additions: [existingAddition],
deletions: [existingDeletion],
},
});
datasetWithExistingChangeLog.add(thingQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
thing.add(thingQuad);
const updatedDataset = removeThing(datasetWithExistingChangeLog, thing);
expect(updatedDataset.internal_changeLog.additions).toEqual([
existingAddition,
]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([
existingDeletion,
thingQuad,
]);
});
it("preserves attached ACLs", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const datasetWithFetchedAcls: SolidDataset & WithAcl = internal_setAcl(
mockSolidDatasetFrom("https://some.vocab/"),
{
resourceAcl: null,
fallbackAcl: null,
}
);
datasetWithFetchedAcls.add(thingQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
thing.add(thingQuad);
const updatedDataset = removeThing(datasetWithFetchedAcls, thing);
expect(updatedDataset.internal_acl).toEqual({
resourceAcl: null,
fallbackAcl: null,
});
});
it("preserves metadata on ACL Datasets", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const aclDataset: AclDataset = Object.assign(dataset(), {
internal_accessTo: "https://arbitrary.pod/resource",
internal_resourceInfo: {
sourceIri: "https://arbitrary.pod/resource.acl",
isRawData: false,
linkedResources: {},
},
});
aclDataset.add(thingQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://some.vocab/subject",
});
thing.add(thingQuad);
const updatedDataset = removeThing(aclDataset, thing);
expect(updatedDataset.internal_accessTo).toBe(
"https://arbitrary.pod/resource"
);
expect(updatedDataset.internal_resourceInfo).toEqual({
sourceIri: "https://arbitrary.pod/resource.acl",
isRawData: false,
linkedResources: {},
});
});
it("returns a Dataset that excludes Quads with a given Subject IRI", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(otherQuad);
const updatedDataset = removeThing(
datasetWithMultipleThings,
"https://some.vocab/subject"
);
expect(Array.from(updatedDataset)).toEqual([otherQuad]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([thingQuad]);
});
it("does not modify the original SolidDataset", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(otherQuad);
removeThing(datasetWithMultipleThings, "https://some.vocab/subject");
expect(Array.from(datasetWithMultipleThings)).toEqual([
thingQuad,
otherQuad,
]);
expect(
(datasetWithMultipleThings as SolidDataset & WithChangeLog)
.internal_changeLog
).toBeUndefined();
});
it("does not modify Quads with unexpected Subjects", () => {
const unexpectedQuad = DataFactory.quad(
DataFactory.variable("Arbitrary unexpected Subject type"),
DataFactory.namedNode("https://arbitrary.vocab/predicate"),
DataFactory.namedNode("https://arbitrary.vocab/object")
);
const datasetWithUnexpectedQuad = dataset();
datasetWithUnexpectedQuad.add(unexpectedQuad);
const thing: Thing = Object.assign(dataset(), {
internal_url: "https://arbitrary.vocab/subject",
});
const updatedDataset = removeThing(datasetWithUnexpectedQuad, thing);
expect(Array.from(updatedDataset)).toEqual([unexpectedQuad]);
});
it("returns a Dataset that excludes Quads with a given NamedNode as their Subject", () => {
const thingQuad = getMockQuad({
subject: "https://some.vocab/subject",
object: "https://some.vocab/new-object",
});
const otherQuad = getMockQuad({
subject: "https://arbitrary-other.vocab/subject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(otherQuad);
const updatedDataset = removeThing(
datasetWithMultipleThings,
DataFactory.namedNode("https://some.vocab/subject")
);
expect(Array.from(updatedDataset)).toEqual([otherQuad]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([thingQuad]);
});
it("can recognise LocalNodes", () => {
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "localSubject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const thingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/old-object")
);
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
const updatedDataset = removeThing(datasetWithMultipleThings, localSubject);
expect(updatedDataset.size).toBe(0);
expect(updatedDataset.internal_changeLog.deletions).toEqual([thingQuad]);
});
it("can reconcile given LocalNodes with existing NamedNodes if the SolidDataset has a resource IRI attached", () => {
const oldThingQuad = getMockQuad({
subject: "https://some.pod/resource#subject",
object: "https://some.vocab/old-object",
});
const datasetWithNamedNode: SolidDataset & WithResourceInfo = Object.assign(
dataset(),
{
internal_resourceInfo: {
sourceIri: "https://some.pod/resource",
isRawData: false,
linkedResources: {},
},
}
);
datasetWithNamedNode.add(oldThingQuad);
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "subject" }
);
const updatedDataset = removeThing(datasetWithNamedNode, localSubject);
expect(updatedDataset.size).toBe(0);
});
it("can reconcile given NamedNodes with existing LocalNodes if the SolidDataset has a resource IRI attached", () => {
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "subject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const thingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/new-object")
);
const datasetWithLocalNode: SolidDataset & WithResourceInfo = Object.assign(
dataset(),
{
internal_resourceInfo: {
sourceIri: "https://some.pod/resource",
isRawData: false,
linkedResources: {},
},
}
);
datasetWithLocalNode.add(thingQuad);
const updatedDataset = removeThing(
datasetWithLocalNode,
DataFactory.namedNode("https://some.pod/resource#subject")
);
expect(updatedDataset.size).toBe(0);
});
it("only removes LocalNodes if the SolidDataset has no known IRI", () => {
const localSubject = Object.assign(
DataFactory.blankNode("Blank node representing a LocalNode"),
{ internal_name: "localSubject" }
);
const mockPredicate = DataFactory.namedNode(
"https://arbitrary.vocab/predicate"
);
const thingQuad = DataFactory.quad(
localSubject,
mockPredicate,
DataFactory.namedNode("https://some.vocab/old-object")
);
const similarSubjectQuad = getMockQuad({
subject: "https://some.pod/resource#localSubject",
});
const datasetWithMultipleThings = dataset();
datasetWithMultipleThings.add(thingQuad);
datasetWithMultipleThings.add(similarSubjectQuad);
const updatedDataset = removeThing(datasetWithMultipleThings, localSubject);
expect(Array.from(updatedDataset)).toEqual([similarSubjectQuad]);
expect(updatedDataset.internal_changeLog.deletions).toEqual([thingQuad]);
});
});
describe("asIri", () => {
it("returns the IRI of a persisted Thing", () => {
const persistedThing: ThingPersisted = Object.assign(dataset(), {
internal_url: "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 localSubject: LocalNode = Object.assign(DataFactory.blankNode(), {
internal_name: "some-name",
});
const localThing = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
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: Thing = Object.assign(dataset(), {
internal_url: "https://some.pod/resource#thing",
});
expect(asUrl(thing as Thing, "https://arbitrary.url")).toBe(
"https://some.pod/resource#thing"
);
});
it("throws an error when a local Thing was given without a base IRI", () => {
const localSubject: LocalNode = Object.assign(DataFactory.blankNode(), {
internal_name: "some-name",
});
const localThing = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
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("toNode", () => {
it("should result in equal LocalNodes for the same ThingLocal", () => {
const localSubject: LocalNode = Object.assign(
DataFactory.blankNode("Arbitrary blank node"),
{ internal_name: "localSubject" }
);
const thing: ThingLocal = Object.assign(dataset(), {
internal_localSubject: localSubject,
});
const node1 = internal_toNode(thing);
const node2 = internal_toNode(thing);
expect(node1.equals(node2)).toBe(true);
});
});
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", () => {
let thingWithValues = createThing({ name: "with-values" });
thingWithValues = addStringNoLocale(
thingWithValues,
"https://some.vocab/predicate",
"Some value"
);
thingWithValues = addStringWithLocale(
thingWithValues,
"https://some.vocab/predicate",
"Some other value",
"en-gb"
);
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"
);
expect(thingAsMarkdown(thingWithValues)).toBe(
"## Thing (no URL yet — identifier: `#with-values`)\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
'- "Some value" (string)\n' +
'- "Some other value" (en-gb 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"
);
});
it("returns a readable version of a Thing that points to other Things", () => {
let thing1 = createThing({ name: "thing1" });
const thing2 = createThing({ name: "thing2" });
const thing3 = mockThingFrom("https://some.pod/resource#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" +
"- <#thing2> (URL)\n" +
"- <https://some.pod/resource#thing3> (URL)\n"
);
});
it("returns a readable version of a Thing with values that we do not explicitly provide convenience functions for", () => {
const thingWithRdfValues = createThing({ name: "with-rdf-values" });
const someBlankNode = DataFactory.blankNode("blank-node-id");
const someLiteral = DataFactory.literal(
"some-serialised-value",
DataFactory.namedNode("https://some.vocab/datatype")
);
thingWithRdfValues.add(
DataFactory.quad(
thingWithRdfValues.internal_localSubject,
DataFactory.namedNode("https://some.vocab/predicate"),
someBlankNode
)
);
thingWithRdfValues.add(
DataFactory.quad(
thingWithRdfValues.internal_localSubject,
DataFactory.namedNode("https://some.vocab/predicate"),
someLiteral
)
);
thingWithRdfValues.add(
DataFactory.quad(
thingWithRdfValues.internal_localSubject,
DataFactory.namedNode("https://some.vocab/predicate"),
DataFactory.quad(
DataFactory.blankNode("Arbitrary Subject in an RDF* nested Quad."),
DataFactory.namedNode("https://some.vocab/predicate"),
DataFactory.literal("Arbitrary Object in an RDF* nested Quad.")
)
)
);
expect(thingAsMarkdown(thingWithRdfValues)).toBe(
"## Thing (no URL yet — identifier: `#with-rdf-values`)\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
"- [blank-node-id] (RDF/JS BlankNode)\n" +
"- [some-serialised-value] (RDF/JS Literal of type: `https://some.vocab/datatype`)\n" +
"- ??? (nested RDF* Quad)\n"
);
});
it("returns a readable version even of a Thing that contains invalid data", () => {
const thingWithValues = createThing({ name: "with-values" });
function addInvalidValue(value: Quad_Object) {
thingWithValues.add(
DataFactory.quad(
thingWithValues.internal_localSubject,
DataFactory.namedNode("https://some.vocab/predicate"),
value
)
);
}
const invalidDatatype: Literal = {
equals: () => false,
language: "",
termType: "Literal",
value: "With invalid datatype",
datatype: new Error("Not a valid datatype") as any,
};
addInvalidValue(invalidDatatype);
addInvalidValue(
DataFactory.literal(
"Not a boolean",
DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#boolean")
)
);
addInvalidValue(
DataFactory.literal(
"Not a datetime",
DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#dateTime")
)
);
addInvalidValue(
DataFactory.literal(
"Not a decimal",
DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#decimal")
)
);
addInvalidValue(
DataFactory.literal(
"Not an integer",
DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#integer")
)
);
addInvalidValue(DataFactory.variable("some-variable"));
expect(thingAsMarkdown(thingWithValues)).toBe(
"## Thing (no URL yet — identifier: `#with-values`)\n" +
"\n" +
"Property: https://some.vocab/predicate\n" +
"- [With invalid datatype] (RDF/JS Literal of unknown type)\n" +
"- Invalid data: `Not a boolean` (boolean)\n" +
"- Invalid data: `Not a datetime` (datetime)