diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index b860027..0000000 --- a/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'jsdom', -}; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..8eb8e6f --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,11 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +const jestConfig: JestConfigWithTsJest = { + preset: "ts-jest", + // automock: true, + testRegex: "\\.test\\.ts$", + testEnvironment: "jsdom", + setupFilesAfterEnv: ["/test/setup.ts"], +}; + +export default jestConfig; diff --git a/package-lock.json b/package-lock.json index 0725dba..941b9b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdom": "^22.1.0", "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", "typescript": "^5.2.2" } }, @@ -688,6 +689,28 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@digitalbazaar/http-client": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-1.2.0.tgz", @@ -1187,6 +1210,30 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.4", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.4.tgz", @@ -1489,6 +1536,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1937,6 +1990,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2063,6 +2122,15 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", @@ -4998,6 +5066,49 @@ "node": ">=10" } }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5098,6 +5209,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.1.3", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", @@ -5304,6 +5421,15 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 31cc1bc..98e7a26 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdom": "^22.1.0", "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", "typescript": "^5.2.2" }, "dependencies": { diff --git a/src/StubFetcher.ts b/src/StubFetcher.ts new file mode 100644 index 0000000..f1cd7b2 --- /dev/null +++ b/src/StubFetcher.ts @@ -0,0 +1,167 @@ +import { EventEmitter } from 'events'; + +class StubHeaders implements Headers { + public static make(data: Record): StubHeaders { + return new StubHeaders(data); + } + + private data: Record; + + private constructor(data: Record) { + this.data = {}; + + for (const [name, value] of Object.entries(data)) { + this.set(name, value); + } + } + // TODO: to make ts compiler happy + public getSetCookie(): string[] { + throw new Error("Method not implemented."); + } + + public [Symbol.iterator](): IterableIterator<[string, string]> { + throw new Error("Method not implemented."); + } + + public entries(): IterableIterator<[string, string]> { + throw new Error("Method not implemented."); + } + + public keys(): IterableIterator { + throw new Error("Method not implemented."); + } + + public values(): IterableIterator { + throw new Error("Method not implemented."); + } + + public append(name: string, value: string): void { + this.data[this.normalizeHeader(name)] = value; + } + + public delete(name: string): void { + delete this.data[this.normalizeHeader(name)]; + } + + public get(name: string): string | null { + return this.data[this.normalizeHeader(name)] ?? null; + } + + public has(name: string): boolean { + return this.normalizeHeader(name) in this.data; + } + + public set(name: string, value: string): void { + this.data[this.normalizeHeader(name)] = value; + } + + public forEach( + callbackfn: (value: string, name: string, parent: Headers) => void + ): void { + for (const [name, value] of Object.entries(this.data)) { + callbackfn(value, name, this); + } + } + + private normalizeHeader(name: string): string { + return name.toLowerCase(); + } +} + + +class StubResponse implements Response { + + public static make( + content: string = '', + headers: Record = {}, + status: number = 200, + ): StubResponse { + return new StubResponse(status, content, headers); + } + + public static notFound(): StubResponse { + return new StubResponse(404); + } + + private content: string; + + public readonly body!: ReadableStream | null; + public readonly bodyUsed!: boolean; + public readonly headers: StubHeaders; + public readonly ok!: boolean; + public readonly redirected!: boolean; + public readonly status: number; + public readonly statusText!: string; + public readonly trailer!: Promise; + public readonly type!: ResponseType; + public readonly url!: string; + + private constructor(status: number, content: string = '', headers: Record = {}) { + this.status = status; + this.content = content; + this.headers = StubHeaders.make(headers); + } + + public async arrayBuffer(): Promise { + throw new Error('StubResponse.arrayBuffer is not implemented'); + } + + public async blob(): Promise { + throw new Error('StubResponse.blob is not implemented'); + } + + public async formData(): Promise { + throw new Error('StubResponse.formData is not implemented'); + } + + public async json(): Promise { + return JSON.parse(this.content); + } + + public async text(): Promise { + return this.content; + } + + public clone(): Response { + return { ...this }; + } + +} + +class StubFetcher extends EventEmitter { + + public fetchSpy!: jest.SpyInstance, [RequestInfo, RequestInit?]>; + + private fetchResponses: Response[] = []; + + public reset(): void { + this.fetchResponses = []; + + this.fetchSpy.mockClear(); + } + + public addFetchNotFoundResponse(): void { + this.fetchResponses.push(StubResponse.notFound()); + } + + public addFetchResponse(content: string = '', headers: Record = {}, status: number = 200): void { + this.fetchResponses.push(StubResponse.make(content, headers, status)); + } + + public async fetch(_: RequestInfo, __?: RequestInit): Promise { + const response = this.fetchResponses.shift(); + + if (!response) { + return new Promise((_, reject) => reject()); + } + + return response; + } + +} + +const stubFetcher = new StubFetcher(); + +// stubFetcher.fetchSpy = jest.spyOn(stubFetcher, 'fetch'); + +export default stubFetcher; diff --git a/src/typeIndexHelpers.ts b/src/typeIndexHelpers.ts index 11edc59..e858196 100644 --- a/src/typeIndexHelpers.ts +++ b/src/typeIndexHelpers.ts @@ -43,7 +43,7 @@ export const registerInTypeIndex = async (args: { typeRegistration.mintUrl(args.typeIndexUrl, true, v4()); - await typeRegistration.withEngine(getEngine()!, () => + return await typeRegistration.withEngine(getEngine()!, () => typeRegistration.save(urlParentDirectory(args.typeIndexUrl) ?? "") ); }; @@ -74,16 +74,19 @@ export async function createTypeIndex( } `; - await Promise.all([ - createSolidDocument(typeIndexUrl, typeIndexBody, fetch), - updateSolidDocument(webId, profileUpdateBody, fetch), // https://reza-soltani.solidcommunity.net/profile/card - ]); - - if (type === "public") { - // TODO Implement updating ACLs for the listing itself to public + try { + const res = await Promise.all([ + createSolidDocument(typeIndexUrl, typeIndexBody, fetch), + updateSolidDocument(webId, profileUpdateBody, fetch), // https://reza-soltani.solidcommunity.net/profile/card + ]); + if (type === "public") { + // TODO Implement updating ACLs for the listing itself to public + } + + return res[0] + } catch (error) { + console.log("🚀 ~ file: typeIndexHelpers.ts:89 ~ error:", error) } - - return typeIndexUrl; } async function findRegistrations( diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..c5a13e4 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,8 @@ +import { installJestPlugin } from '@noeldemartin/solid-utils'; +import { bootSolidModels } from 'soukai-solid'; + +installJestPlugin(); +beforeEach(() => jest.clearAllMocks()); +beforeEach(() => bootSolidModels()); + +process.on('unhandledRejection', (err) => fail(err)); diff --git a/test/unit/fixtures/card.ttl b/test/unit/fixtures/card.ttl new file mode 100644 index 0000000..c1cdf32 --- /dev/null +++ b/test/unit/fixtures/card.ttl @@ -0,0 +1,32 @@ +@prefix : <#>. +@prefix acl: . +@prefix foaf: . +@prefix ldp: . +@prefix schema: . +@prefix solid: . +@prefix space: . +@prefix pro: <./>. +@prefix inbox: . +@prefix sol: . + +pro:card a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. + +:me + a schema:Person, foaf:Person; + acl:trustedApp + [ + acl:mode acl:Append, acl:Control, acl:Read, acl:Write; + acl:origin + ], + [ + acl:mode acl:Append, acl:Read, acl:Write; + acl:origin + ]; + ldp:inbox inbox:; + space:preferencesFile ; + space:storage sol:; + solid:account sol:; + solid:oidcIssuer ; + solid:privateTypeIndex ; + solid:publicTypeIndex ; + foaf:name "solid-dm". diff --git a/test/unit/typeIndexHelpers.test.ts b/test/unit/typeIndexHelpers.test.ts index 3bcf85a..01b00bf 100644 --- a/test/unit/typeIndexHelpers.test.ts +++ b/test/unit/typeIndexHelpers.test.ts @@ -1,14 +1,137 @@ -// import { Fetch} from "soukai-solid"; -import { getTypeIndexFromProfile } from "../../src/typeIndexHelpers"; +import { + getTypeIndexFromProfile, + registerInTypeIndex, + createTypeIndex, +} from "../../src/typeIndexHelpers"; +import stubFetcher from '../../src/StubFetcher' +import { readFileSync } from "fs"; +import { SolidEngine, SolidTypeRegistration } from "soukai-solid"; +import { setEngine } from "soukai"; + + +function loadFixture(name: string): T { + const raw = readFileSync(`${__dirname}/fixtures/${name}`).toString(); + return /\.json(ld)$/.test(name) ? JSON.parse(raw) : raw; +} describe("getTypeIndexFromProfile", () => { - it("fetches the type index", async () => { + + let fetch: jest.Mock, [RequestInfo, RequestInit?]>; + + beforeEach(() => { + stubFetcher.fetchSpy = jest.spyOn(stubFetcher, 'fetch'); + + fetch = jest.fn((...args) => stubFetcher.fetch(...args)); + + setEngine(new SolidEngine(fetch)); + }); + + it("fetches the public type index URL", async () => { + // Arrange + stubFetcher.addFetchResponse(loadFixture("card.ttl"), { + "WAC-Allow": 'public="read"', + }); + const args = { - webId: "https://michielbdejong.solidcommunity.net/profile/card#me", - // fetch: {}, FIXME: https://github.com/pondersource/soukai-solid-utils/issues/17 + webId: "https://fake-pod.com/profile/card#me", + fetch, typePredicate: "solid:publicTypeIndex", }; + const result = await getTypeIndexFromProfile(args as any); - expect(result).toBe("https://michielbdejong.solidcommunity.net/settings/publicTypeIndex.ttl"); + expect(result).toBe("https://fake-pod.com/settings/publicTypeIndex.ttl"); }); + + it("fetches the private type index URL", async () => { + stubFetcher.addFetchResponse(loadFixture("card.ttl"), { + "WAC-Allow": 'public="read"', + }); + const args = { + webId: "https://fake-pod.com/profile/card#me", + fetch, + typePredicate: "solid:privateTypeIndex", + }; + const result = await getTypeIndexFromProfile(args as any); + expect(result).toBe("https://fake-pod.com/settings/privateTypeIndex.ttl"); + }); + + it("registers instanceContainer In TypeIndex", async () => { + + // Arrange + SolidTypeRegistration.collection = "https://fake-pod.com/settings/"; + + const forClass = "http://www.w3.org/2002/01/bookmark#Bookmark" + const instanceContainer = "https://solid-dm.solidcommunity.net/bookmarks/" + const typeIndexUrl = "https://fake-pod.com/settings/privateTypeIndex.ttl" + + + // Act + // calling this twise because soukai-solid first calls a GET request and then a PATCH always + // (maybe this can count as a problem with soukai) + stubFetcher.addFetchResponse(); + stubFetcher.addFetchResponse(); + + const result = await registerInTypeIndex({ forClass, instanceContainer, typeIndexUrl }) + + + // Assert + // expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith(typeIndexUrl, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('INSERT DATA { <'), headers: expect.objectContaining({ 'Content-Type': 'application/sparql-update' }) })); + expect(fetch).toHaveBeenCalledWith(typeIndexUrl, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('> a .') })); + expect(fetch).toHaveBeenCalledWith(typeIndexUrl, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('> .') })); + expect(fetch).toHaveBeenCalledWith(typeIndexUrl, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('> . }') })); + expect(result.forClass).toEqual(forClass); + expect(result.instanceContainer).toEqual(instanceContainer); + }) + it("Creates public typeIndex document", async () => { + + // Arrange + const webId = "https://fake-pod.com/profile/card#me" + const expectedTypeIndexUrl = "https://fake-pod.com/settings/publicTypeIndex.ttl" + + stubFetcher.addFetchResponse(); + stubFetcher.addFetchResponse(); + + // Act + const result = await createTypeIndex(webId, "public", fetch) + + // Assert + expect(result?.url).toEqual(expectedTypeIndexUrl); + + // expect(fetch).toHaveBeenCalledTimes(2); + // adding the link into the profile + expect(fetch).toHaveBeenCalledWith(webId, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('INSERT DATA {'), headers: expect.objectContaining({ 'Content-Type': 'application/sparql-update' }) })); + expect(fetch).toHaveBeenCalledWith(webId, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining(' .') })); + + // create the typeIndex document + expect(fetch).toHaveBeenCalledWith(expectedTypeIndexUrl, expect.objectContaining({ method: 'PUT', body: '<> a .', headers: expect.objectContaining({ 'Content-Type': 'text/turtle' }) })); + + }) + it("Creates private typeIndex document", async () => { + + // Arrange + const webId = "https://fake-pod.com/profile/card#me" + const expectedTypeIndexUrl = "https://fake-pod.com/settings/privateTypeIndex.ttl" + + + stubFetcher.addFetchResponse(); + stubFetcher.addFetchResponse(); + + // Act + const result = await createTypeIndex(webId, "private", fetch) + + // Assert + expect(result?.url).toEqual(expectedTypeIndexUrl); + + // expect(fetch).toHaveBeenCalledTimes(2); + // adding the link into the profile + expect(fetch).toHaveBeenCalledWith(webId, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('INSERT DATA {'), headers: expect.objectContaining({ 'Content-Type': 'application/sparql-update' }) })); + expect(fetch).toHaveBeenCalledWith(webId, expect.objectContaining({ method: 'PATCH', body: expect.stringContaining(' .') })); + + // create the typeIndex document + expect(fetch).toHaveBeenCalledWith(expectedTypeIndexUrl, expect.objectContaining({ method: 'PUT', body: expect.stringContaining('<> a'), headers: expect.objectContaining({ 'Content-Type': 'text/turtle' }) })); + expect(fetch).toHaveBeenCalledWith(expectedTypeIndexUrl, expect.objectContaining({ method: 'PUT', body: expect.stringContaining(',') })); + expect(fetch).toHaveBeenCalledWith(expectedTypeIndexUrl, expect.objectContaining({ method: 'PUT', body: expect.stringContaining(' .') })); + + }) }); \ No newline at end of file