diff --git a/config/storage/middleware/stores/patching.json b/config/storage/middleware/stores/patching.json index 526cc08c4..089e414bb 100644 --- a/config/storage/middleware/stores/patching.json +++ b/config/storage/middleware/stores/patching.json @@ -5,10 +5,26 @@ "comment": "Allows for PATCH operations on stores that don't have native support.", "@id": "urn:solid-server:default:ResourceStore_Patching", "@type": "PatchingStore", - "patcher": { + "patchHandler": { "@id": "urn:solid-server:default:PatchHandler", - "@type": "SparqlUpdatePatchHandler", - "converter": { "@id": "urn:solid-server:default:RepresentationConverter" } + "@type": "RepresentationPatchHandler", + "patcher": { + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Makes sure PATCH operations on containers target the metadata.", + "@type": "ContainerPatcher", + "patcher": { "@type": "SparqlUpdatePatcher" } + }, + { + "@type": "ConvertingPatcher", + "patcher": { "@type": "SparqlUpdatePatcher" }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, + "intermediateType": "internal/quads", + "defaultType": "text/turtle" + } + ] + } } } ] diff --git a/src/index.ts b/src/index.ts index 1162c4d50..c918d2397 100644 --- a/src/index.ts +++ b/src/index.ts @@ -259,9 +259,12 @@ export * from './storage/mapping/FixedContentTypeMapper'; export * from './storage/mapping/SubdomainExtensionBasedMapper'; // Storage/Patch -export * from './storage/patch/ConvertingPatchHandler'; +export * from './storage/patch/ContainerPatcher'; +export * from './storage/patch/ConvertingPatcher'; export * from './storage/patch/PatchHandler'; -export * from './storage/patch/SparqlUpdatePatchHandler'; +export * from './storage/patch/RepresentationPatcher'; +export * from './storage/patch/RepresentationPatchHandler'; +export * from './storage/patch/SparqlUpdatePatcher'; // Storage/Routing export * from './storage/routing/BaseUrlRouterRule'; diff --git a/src/storage/PatchingStore.ts b/src/storage/PatchingStore.ts index b256867bf..06e5d07c7 100644 --- a/src/storage/PatchingStore.ts +++ b/src/storage/PatchingStore.ts @@ -12,11 +12,11 @@ import type { ResourceStore } from './ResourceStore'; * otherwise the {@link PatchHandler} will be called instead. */ export class PatchingStore extends PassthroughStore { - private readonly patcher: PatchHandler; + private readonly patchHandler: PatchHandler; - public constructor(source: T, patcher: PatchHandler) { + public constructor(source: T, patchHandler: PatchHandler) { super(source); - this.patcher = patcher; + this.patchHandler = patchHandler; } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, @@ -25,7 +25,7 @@ export class PatchingStore extends Pass return await this.source.modifyResource(identifier, patch, conditions); } catch (error: unknown) { if (NotImplementedHttpError.isInstance(error)) { - return this.patcher.handleSafe({ source: this.source, identifier, patch }); + return this.patchHandler.handleSafe({ source: this.source, identifier, patch }); } throw error; } diff --git a/src/storage/patch/ContainerPatcher.ts b/src/storage/patch/ContainerPatcher.ts new file mode 100644 index 000000000..073efffbd --- /dev/null +++ b/src/storage/patch/ContainerPatcher.ts @@ -0,0 +1,51 @@ +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import { INTERNAL_QUADS } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { isContainerIdentifier } from '../../util/PathUtil'; +import { SOLID_META } from '../../util/Vocabularies'; +import type { RepresentationPatcherInput } from './RepresentationPatcher'; +import { RepresentationPatcher } from './RepresentationPatcher'; + +/** + * A `RepresentationPatcher` specifically for patching containers. + * A new body will be constructed from the metadata by removing all generated metadata. + * This body will be passed to the wrapped patcher. + */ +export class ContainerPatcher extends RepresentationPatcher { + private readonly patcher: RepresentationPatcher; + + public constructor(patcher: RepresentationPatcher) { + super(); + this.patcher = patcher; + } + + public async canHandle(input: RepresentationPatcherInput): Promise { + const { identifier, representation } = input; + if (!isContainerIdentifier(identifier)) { + throw new NotImplementedHttpError('Only containers are supported.'); + } + // Verify the patcher can handle a representation containing the metadata + let containerPlaceholder = representation; + if (representation) { + containerPlaceholder = new BasicRepresentation([], representation.metadata, INTERNAL_QUADS); + } + await this.patcher.canHandle({ ...input, representation: containerPlaceholder }); + } + + public async handle(input: RepresentationPatcherInput): Promise { + const { identifier, representation } = input; + if (!representation) { + return await this.patcher.handle(input); + } + // Remove all generated metadata to prevent it from being stored permanently + representation.metadata.removeQuads( + representation.metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata), + ); + const quads = representation.metadata.quads(); + + // We do not copy the original metadata here, otherwise it would put back triples that might be deleted + const containerRepresentation = new BasicRepresentation(quads, identifier, INTERNAL_QUADS, false); + return await this.patcher.handle({ ...input, representation: containerRepresentation }); + } +} diff --git a/src/storage/patch/ConvertingPatchHandler.ts b/src/storage/patch/ConvertingPatchHandler.ts deleted file mode 100644 index 69afb0c36..000000000 --- a/src/storage/patch/ConvertingPatchHandler.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Representation } from '../../ldp/representation/Representation'; -import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; -import { getLoggerFor } from '../../logging/LogUtil'; -import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; -import type { RepresentationConverter } from '../conversion/RepresentationConverter'; -import type { ResourceStore } from '../ResourceStore'; -import type { PatchHandlerArgs } from './PatchHandler'; -import { PatchHandler } from './PatchHandler'; - -/** - * An abstract patch handler. - * - * A `ConvertingPatchHandler` converts a document to its `intermediateType`, - * handles the patch operation, and then converts back to its original type. - * This abstract class covers all of the above except of handling the patch operation, - * for which the abstract `patch` function has to be implemented. - * - * In case there is no resource yet and a new one needs to be created, - * the `patch` function will be called without a Representation - * and the result will be converted to the `defaultType`. - */ -export abstract class ConvertingPatchHandler extends PatchHandler { - protected readonly logger = getLoggerFor(this); - - private readonly converter: RepresentationConverter; - protected readonly intermediateType: string; - protected readonly defaultType: string; - - /** - * @param converter - Converter that will be used to generate intermediate Representation. - * @param intermediateType - Content-type of the intermediate Representation. - * @param defaultType - Content-type in case a new resource gets created. - */ - protected constructor(converter: RepresentationConverter, intermediateType: string, defaultType: string) { - super(); - this.converter = converter; - this.intermediateType = intermediateType; - this.defaultType = defaultType; - } - - public async handle(input: PatchHandlerArgs): Promise { - const { source, identifier } = input; - const { representation, contentType } = await this.toIntermediate(source, identifier); - - const patched = await this.patch(input, representation); - - // Convert back to the original type and write the result - const converted = await this.converter.handleSafe({ - representation: patched, - identifier, - preferences: { type: { [contentType]: 1 }}, - }); - return source.setRepresentation(identifier, converted); - } - - /** - * Acquires the resource from the source and converts it to the intermediate type if it was found. - * Also returns the contentType that should be used when converting back before setting the representation. - */ - protected async toIntermediate(source: ResourceStore, identifier: ResourceIdentifier): - Promise<{ representation?: Representation; contentType: string }> { - let converted: Representation | undefined; - let contentType: string; - try { - const representation = await source.getRepresentation(identifier, {}); - contentType = representation.metadata.contentType!; - const preferences = { type: { [this.intermediateType]: 1 }}; - converted = await this.converter.handleSafe({ representation, identifier, preferences }); - } catch (error: unknown) { - // Solid, §5.1: "When a successful PUT or PATCH request creates a resource, - // the server MUST use the effective request URI to assign the URI to that resource." - // https://solid.github.io/specification/protocol#resource-type-heuristics - if (!NotFoundHttpError.isInstance(error)) { - throw error; - } - contentType = this.defaultType; - this.logger.debug(`Patching new resource ${identifier.path}`); - } - return { representation: converted, contentType }; - } - - /** - * Patch the given representation based on the patch arguments. - * In case representation is not defined a new Representation should be created. - * @param input - Arguments that were passed to the initial `handle` call. - * @param representation - Representation acquired from the source and converted to the intermediate type. - */ - protected abstract patch(input: PatchHandlerArgs, representation?: Representation): Promise; -} diff --git a/src/storage/patch/ConvertingPatcher.ts b/src/storage/patch/ConvertingPatcher.ts new file mode 100644 index 000000000..bc3fbad04 --- /dev/null +++ b/src/storage/patch/ConvertingPatcher.ts @@ -0,0 +1,79 @@ +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import { getLoggerFor } from '../../logging/LogUtil'; +import type { RepresentationConverter } from '../conversion/RepresentationConverter'; +import type { RepresentationPatcherInput } from './RepresentationPatcher'; +import { RepresentationPatcher } from './RepresentationPatcher'; + +/** + * A `ConvertingPatcher` converts a document to its `intermediateType`, + * sends the result to the wrapped patcher, and then converts back to its original type. + * No changes will take place if no `intermediateType` is provided. + * + * In case there is no resource yet and a new one needs to be created, + * the result of the wrapped patcher will be converted to the provided `defaultType`. + * In case no `defaultType` is provided, the patcher output will be returned directly. + */ +export class ConvertingPatcher extends RepresentationPatcher { + protected readonly logger = getLoggerFor(this); + + private readonly patcher: RepresentationPatcher; + private readonly converter: RepresentationConverter; + private readonly intermediateType?: string; + private readonly defaultType?: string; + + /** + * @param patcher - Patcher that will be called with the Representation. + * @param converter - Converter that will be used to generate intermediate Representation. + * @param intermediateType - Content-type of the intermediate Representation if conversion is needed. + * @param defaultType - Content-type in case a new resource gets created and needs to be converted. + */ + public constructor(patcher: RepresentationPatcher, converter: RepresentationConverter, intermediateType?: string, + defaultType?: string) { + super(); + this.patcher = patcher; + this.converter = converter; + this.intermediateType = intermediateType; + this.defaultType = defaultType; + } + + public async canHandle(input: RepresentationPatcherInput): Promise { + // Verify the converter can handle the input representation if needed + const { identifier, representation } = input; + let convertedPlaceholder = representation; + if (representation && this.intermediateType) { + const preferences = { type: { [this.intermediateType]: 1 }}; + await this.converter.canHandle({ representation, identifier, preferences }); + convertedPlaceholder = new BasicRepresentation([], representation.metadata, this.intermediateType); + } + + // Verify the patcher can handle the (converted) representation + await this.patcher.canHandle({ ...input, representation: convertedPlaceholder }); + } + + public async handle(input: RepresentationPatcherInput): Promise { + const { identifier, representation } = input; + let outputType: string | undefined; + let converted = representation; + if (!representation) { + // If there is no representation the output will need to be converted to the default type + outputType = this.defaultType; + } else if (this.intermediateType) { + // Convert incoming representation to the requested type + outputType = representation.metadata.contentType; + const preferences = { type: { [this.intermediateType]: 1 }}; + converted = await this.converter.handle({ representation, identifier, preferences }); + } + + // Call the wrapped patcher with the (potentially) converted representation + let result = await this.patcher.handle({ ...input, representation: converted }); + + // Convert the output back to its original type or the default type depending on what was set + if (outputType) { + const preferences = { type: { [outputType]: 1 }}; + result = await this.converter.handle({ representation: result, identifier, preferences }); + } + + return result; + } +} diff --git a/src/storage/patch/PatchHandler.ts b/src/storage/patch/PatchHandler.ts index 7fe155d18..b56edb449 100644 --- a/src/storage/patch/PatchHandler.ts +++ b/src/storage/patch/PatchHandler.ts @@ -3,7 +3,7 @@ import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdenti import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { ResourceStore } from '../ResourceStore'; -export type PatchHandlerArgs = { +export type PatchHandlerInput = { source: T; identifier: ResourceIdentifier; patch: Patch; @@ -13,4 +13,4 @@ export type PatchHandlerArgs = { * Executes the given Patch. */ export abstract class PatchHandler - extends AsyncHandler, ResourceIdentifier[]> {} + extends AsyncHandler, ResourceIdentifier[]> {} diff --git a/src/storage/patch/RepresentationPatchHandler.ts b/src/storage/patch/RepresentationPatchHandler.ts new file mode 100644 index 000000000..703d86468 --- /dev/null +++ b/src/storage/patch/RepresentationPatchHandler.ts @@ -0,0 +1,47 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { PatchHandlerInput } from './PatchHandler'; +import { PatchHandler } from './PatchHandler'; +import type { RepresentationPatcher } from './RepresentationPatcher'; + +/** + * Handles a patch operation by getting the representation from the store, applying a `RepresentationPatcher`, + * and then writing the result back to the store. + * + * In case there is no original representation (the store throws a `NotFoundHttpError`), + * the patcher is expected to create a new one. + */ +export class RepresentationPatchHandler extends PatchHandler { + protected readonly logger = getLoggerFor(this); + + private readonly patcher: RepresentationPatcher; + + public constructor(patcher: RepresentationPatcher) { + super(); + this.patcher = patcher; + } + + public async handle({ source, patch, identifier }: PatchHandlerInput): Promise { + // Get the representation from the store + let representation: Representation | undefined; + try { + representation = await source.getRepresentation(identifier, {}); + } catch (error: unknown) { + // Solid, §5.1: "When a successful PUT or PATCH request creates a resource, + // the server MUST use the effective request URI to assign the URI to that resource." + // https://solid.github.io/specification/protocol#resource-type-heuristics + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + this.logger.debug(`Patching new resource ${identifier.path}`); + } + + // Patch it + const patched = await this.patcher.handleSafe({ patch, identifier, representation }); + + // Write it back to the store + return source.setRepresentation(identifier, patched); + } +} diff --git a/src/storage/patch/RepresentationPatcher.ts b/src/storage/patch/RepresentationPatcher.ts new file mode 100644 index 000000000..3c9b4b093 --- /dev/null +++ b/src/storage/patch/RepresentationPatcher.ts @@ -0,0 +1,15 @@ +import type { Patch } from '../../ldp/http/Patch'; +import type { Representation } from '../../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; + +export interface RepresentationPatcherInput { + identifier: ResourceIdentifier; + patch: Patch; + representation?: Representation; +} + +/** + * Handles the patching of a specific Representation. + */ +export abstract class RepresentationPatcher extends AsyncHandler {} diff --git a/src/storage/patch/SparqlUpdatePatchHandler.ts b/src/storage/patch/SparqlUpdatePatcher.ts similarity index 83% rename from src/storage/patch/SparqlUpdatePatchHandler.ts rename to src/storage/patch/SparqlUpdatePatcher.ts index 07ffe9a44..d77749644 100644 --- a/src/storage/patch/SparqlUpdatePatchHandler.ts +++ b/src/storage/patch/SparqlUpdatePatcher.ts @@ -11,50 +11,48 @@ import type { SparqlUpdatePatch } from '../../ldp/http/SparqlUpdatePatch'; import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; import type { Representation } from '../../ldp/representation/Representation'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; -import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import { getLoggerFor } from '../../logging/LogUtil'; import { INTERNAL_QUADS } from '../../util/ContentTypes'; +import { InternalServerError } from '../../util/errors/InternalServerError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { readableToQuads, readableToString } from '../../util/StreamUtil'; -import type { RepresentationConverter } from '../conversion/RepresentationConverter'; -import { ConvertingPatchHandler } from './ConvertingPatchHandler'; -import type { PatchHandlerArgs } from './PatchHandler'; +import { RepresentationPatcher } from './RepresentationPatcher'; +import type { RepresentationPatcherInput } from './RepresentationPatcher'; /** * Supports application/sparql-update PATCH requests on RDF resources. * * Only DELETE/INSERT updates without variables are supported. */ -export class SparqlUpdatePatchHandler extends ConvertingPatchHandler { +export class SparqlUpdatePatcher extends RepresentationPatcher { protected readonly logger = getLoggerFor(this); private readonly engine: ActorInitSparql; - public constructor(converter: RepresentationConverter, defaultType = 'text/turtle') { - super(converter, INTERNAL_QUADS, defaultType); + public constructor() { + super(); this.engine = newEngine(); } - public async canHandle({ patch }: PatchHandlerArgs): Promise { + public async canHandle({ patch }: RepresentationPatcherInput): Promise { if (!this.isSparqlUpdate(patch)) { throw new NotImplementedHttpError('Only SPARQL update patches are supported'); } } - public async handle(input: PatchHandlerArgs): Promise { + public async handle(input: RepresentationPatcherInput): Promise { // Verify the patch - const { patch } = input; + const { patch, representation, identifier } = input; const op = (patch as SparqlUpdatePatch).algebra; // In case of a NOP we can skip everything if (op.type === Algebra.types.NOP) { - return []; + return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false); } this.validateUpdate(op); - // Only start conversion if we know the operation is valid - return super.handle(input); + return this.patch(input); } private isSparqlUpdate(patch: Patch): patch is SparqlUpdatePatch { @@ -117,13 +115,15 @@ export class SparqlUpdatePatchHandler extends ConvertingPatchHandler { /** * Apply the given algebra operation to the given identifier. */ - protected async patch(input: PatchHandlerArgs, representation?: Representation): Promise { - const { identifier, patch } = input; + private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise { let result: Store; let metadata: RepresentationMetadata; if (representation) { ({ metadata } = representation); + if (metadata.contentType !== INTERNAL_QUADS) { + throw new InternalServerError('Quad stream was expected for patching.'); + } result = await readableToQuads(representation.data); this.logger.debug(`${result.size} quads in ${identifier.path}.`); } else { @@ -139,6 +139,6 @@ export class SparqlUpdatePatchHandler extends ConvertingPatchHandler { this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`); - return new BasicRepresentation(result.match() as unknown as Readable, metadata); + return new BasicRepresentation(result.match() as unknown as Readable, metadata, false); } } diff --git a/test/integration/LdpHandlerWithoutAuth.test.ts b/test/integration/LdpHandlerWithoutAuth.test.ts index c68cf7027..762626296 100644 --- a/test/integration/LdpHandlerWithoutAuth.test.ts +++ b/test/integration/LdpHandlerWithoutAuth.test.ts @@ -358,4 +358,40 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC // DELETE expect(await deleteResource(documentUrl)).toBeUndefined(); }); + + it('can handle simple SPARQL updates on containers.', async(): Promise => { + // POST + const body = [ ' .', + ' .' ].join('\n'); + let response = await postResource(baseUrl, { contentType: 'text/turtle', body, isContainer: true }); + const documentUrl = response.headers.get('location')!; + + // PATCH + const query = [ 'DELETE { }', + 'INSERT { }', + 'WHERE {}', + ].join('\n'); + await patchResource(documentUrl, query); + + // GET + response = await getResource(documentUrl); + const parser = new Parser({ baseIRI: baseUrl }); + const quads = parser.parse(await response.text()); + const store = new Store(quads); + expect(store.countQuads( + namedNode('http://test.com/s3'), + namedNode('http://test.com/p3'), + namedNode('http://test.com/o3'), + null, + )).toBe(1); + expect(store.countQuads( + namedNode('http://test.com/s1'), + namedNode('http://test.com/p1'), + namedNode('http://test.com/o1'), + null, + )).toBe(0); + + // DELETE + expect(await deleteResource(documentUrl)).toBeUndefined(); + }); }); diff --git a/test/integration/ServerFetch.test.ts b/test/integration/ServerFetch.test.ts index f68832fc3..d890888c2 100644 --- a/test/integration/ServerFetch.test.ts +++ b/test/integration/ServerFetch.test.ts @@ -174,4 +174,23 @@ describe('A Solid server', (): void => { }); expect(res.status).toBe(205); }); + + it('can PATCH containers.', async(): Promise => { + const url = `${baseUrl}containerPATCH/`; + await fetch(url, { + method: 'PUT', + headers: { + 'content-type': 'text/turtle', + }, + body: ' .', + }); + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'content-type': 'application/sparql-update', + }, + body: 'INSERT DATA { . }', + }); + expect(res.status).toBe(205); + }); }); diff --git a/test/unit/storage/patch/ContainerPatcher.test.ts b/test/unit/storage/patch/ContainerPatcher.test.ts new file mode 100644 index 000000000..9012c2a72 --- /dev/null +++ b/test/unit/storage/patch/ContainerPatcher.test.ts @@ -0,0 +1,91 @@ +import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; +import type { Patch } from '../../../../src/ldp/http/Patch'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import { ContainerPatcher } from '../../../../src/storage/patch/ContainerPatcher'; +import type { + RepresentationPatcherInput, + RepresentationPatcher, +} from '../../../../src/storage/patch/RepresentationPatcher'; +import { SOLID_META } from '../../../../src/util/Vocabularies'; +const { namedNode, quad } = DataFactory; + +describe('A ContainerPatcher', (): void => { + const identifier: ResourceIdentifier = { path: 'http://test.com/foo/' }; + const patch: Patch = new BasicRepresentation([], 'type/patch'); + let representation: Representation; + let args: RepresentationPatcherInput; + const patchResult = new BasicRepresentation([], 'internal/quads'); + let patcher: jest.Mocked; + let containerPatcher: ContainerPatcher; + + beforeEach(async(): Promise => { + representation = new BasicRepresentation([], 'internal/quads'); + args = { patch, identifier, representation }; + + patcher = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(patchResult), + } as any; + + containerPatcher = new ContainerPatcher(patcher); + }); + + it('can only handle container identifiers.', async(): Promise => { + args.identifier = { path: 'http://test.com/foo' }; + await expect(containerPatcher.canHandle(args)).rejects.toThrow('Only containers are supported.'); + }); + + it('checks if the patcher can handle the input if there is no representation.', async(): Promise => { + delete args.representation; + + await expect(containerPatcher.canHandle(args)).resolves.toBeUndefined(); + + patcher.canHandle.mockRejectedValueOnce(new Error('unsupported patch')); + await expect(containerPatcher.canHandle(args)).rejects.toThrow('unsupported patch'); + }); + + it('sends a mock representation with the correct type to the patcher to check support.', async(): Promise => { + await expect(containerPatcher.canHandle(args)).resolves.toBeUndefined(); + expect(patcher.canHandle).toHaveBeenCalledTimes(1); + expect(patcher.canHandle.mock.calls[0][0].representation?.metadata.contentType).toBe('internal/quads'); + }); + + it('passes the arguments to the patcher if there is no representation.', async(): Promise => { + delete args.representation; + await expect(containerPatcher.handle(args)).resolves.toBe(patchResult); + expect(patcher.handle).toHaveBeenCalledTimes(1); + expect(patcher.handle).toHaveBeenLastCalledWith(args); + }); + + it('creates a new representation with all generated metadata removed.', async(): Promise => { + const triples = [ + quad(namedNode('a'), namedNode('real'), namedNode('triple')), + quad(namedNode('a'), namedNode('generated'), namedNode('triple')), + ]; + const metadata = new RepresentationMetadata(identifier); + metadata.addQuad(triples[0].subject as any, triples[0].predicate as any, triples[0].object as any); + // Make one of the triples generated + metadata.addQuad(triples[0].subject as any, + triples[0].predicate as any, + triples[0].object as any, + SOLID_META.terms.ResponseMetadata); + args.representation = new BasicRepresentation(triples, metadata); + + await expect(containerPatcher.handle(args)).resolves.toBe(patchResult); + expect(patcher.handle).toHaveBeenCalledTimes(1); + const callArgs = patcher.handle.mock.calls[0][0]; + expect(callArgs.identifier).toBe(identifier); + expect(callArgs.patch).toBe(patch); + // Only content-type metadata + expect(callArgs.representation?.metadata.quads()).toHaveLength(1); + expect(callArgs.representation?.metadata.contentType).toBe('internal/quads'); + // Generated data got removed + const data = await arrayifyStream(callArgs.representation!.data); + expect(data).toHaveLength(1); + expect(data[0].predicate.value).toBe('real'); + }); +}); diff --git a/test/unit/storage/patch/ConvertingPatchHandlers.test.ts b/test/unit/storage/patch/ConvertingPatchHandlers.test.ts deleted file mode 100644 index 7e7271827..000000000 --- a/test/unit/storage/patch/ConvertingPatchHandlers.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { Patch } from '../../../../src/ldp/http/Patch'; -import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; -import type { Representation } from '../../../../src/ldp/representation/Representation'; -import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; -import type { - RepresentationConverter, - RepresentationConverterArgs, -} from '../../../../src/storage/conversion/RepresentationConverter'; -import { ConvertingPatchHandler } from '../../../../src/storage/patch/ConvertingPatchHandler'; -import type { PatchHandlerArgs } from '../../../../src/storage/patch/PatchHandler'; -import type { ResourceStore } from '../../../../src/storage/ResourceStore'; -import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; - -class SimpleConvertingPatchHandler extends ConvertingPatchHandler { - private readonly type: string; - - public constructor(converter: RepresentationConverter, intermediateType: string, defaultType: string) { - super(converter, intermediateType, defaultType); - this.type = intermediateType; - } - - public async patch(input: PatchHandlerArgs, representation?: Representation): Promise { - return representation ? - new BasicRepresentation('patched', representation.metadata) : - new BasicRepresentation('patched', input.identifier, this.type); - } -} - -describe('A ConvertingPatchHandler', (): void => { - const intermediateType = 'internal/quads'; - const defaultType = 'text/turtle'; - const identifier: ResourceIdentifier = { path: 'http://test.com/foo' }; - const patch: Patch = new BasicRepresentation([], 'type/patch'); - const representation: Representation = new BasicRepresentation([], 'application/trig'); - let source: jest.Mocked; - let args: PatchHandlerArgs; - let converter: jest.Mocked; - let handler: jest.Mocked; - - beforeEach(async(): Promise => { - converter = { - handleSafe: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise => - new BasicRepresentation('converted', Object.keys(preferences.type!)[0])), - } as any; - - source = { - getRepresentation: jest.fn().mockResolvedValue(representation), - setRepresentation: jest.fn(async(id: ResourceIdentifier): Promise => [ id ]), - } as any; - - args = { patch, identifier, source }; - - handler = new SimpleConvertingPatchHandler(converter, intermediateType, defaultType) as any; - jest.spyOn(handler, 'patch'); - }); - - it('converts the representation before calling the patch function.', async(): Promise => { - await expect(handler.handle(args)).resolves.toEqual([ identifier ]); - - // Convert input - expect(source.getRepresentation).toHaveBeenCalledTimes(1); - expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { }); - expect(converter.handleSafe).toHaveBeenCalledTimes(2); - expect(converter.handleSafe).toHaveBeenCalledWith({ - representation: await source.getRepresentation.mock.results[0].value, - identifier, - preferences: { type: { [intermediateType]: 1 }}, - }); - - // Patch - expect(handler.patch).toHaveBeenCalledTimes(1); - expect(handler.patch).toHaveBeenLastCalledWith(args, await converter.handleSafe.mock.results[0].value); - - // Convert back - expect(converter.handleSafe).toHaveBeenLastCalledWith({ - representation: await handler.patch.mock.results[0].value, - identifier, - preferences: { type: { 'application/trig': 1 }}, - }); - expect(source.setRepresentation).toHaveBeenCalledTimes(1); - expect(source.setRepresentation) - .toHaveBeenLastCalledWith(identifier, await converter.handleSafe.mock.results[1].value); - expect(source.setRepresentation.mock.calls[0][1].metadata.contentType).toBe(representation.metadata.contentType); - }); - - it('expects the patch function to create a new representation if there is none.', async(): Promise => { - source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); - - await expect(handler.handle(args)).resolves.toEqual([ identifier ]); - - // Try to get input - expect(source.getRepresentation).toHaveBeenCalledTimes(1); - expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { }); - - // Patch - expect(handler.patch).toHaveBeenCalledTimes(1); - expect(handler.patch).toHaveBeenLastCalledWith(args, undefined); - - // Convert new Representation to default type - expect(converter.handleSafe).toHaveBeenCalledTimes(1); - expect(converter.handleSafe).toHaveBeenLastCalledWith({ - representation: await handler.patch.mock.results[0].value, - identifier, - preferences: { type: { [defaultType]: 1 }}, - }); - expect(source.setRepresentation).toHaveBeenCalledTimes(1); - expect(source.setRepresentation) - .toHaveBeenLastCalledWith(identifier, await converter.handleSafe.mock.results[0].value); - expect(source.setRepresentation.mock.calls[0][1].metadata.contentType).toBe(defaultType); - }); - - it('rethrows the error if something goes wrong getting the representation.', async(): Promise => { - const error = new Error('bad data'); - source.getRepresentation.mockRejectedValueOnce(error); - - await expect(handler.handle(args)).rejects.toThrow(error); - }); -}); diff --git a/test/unit/storage/patch/ConvertingPatcher.test.ts b/test/unit/storage/patch/ConvertingPatcher.test.ts new file mode 100644 index 000000000..3d6704424 --- /dev/null +++ b/test/unit/storage/patch/ConvertingPatcher.test.ts @@ -0,0 +1,129 @@ +import type { Patch } from '../../../../src/ldp/http/Patch'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import type { + RepresentationConverter, + RepresentationConverterArgs, +} from '../../../../src/storage/conversion/RepresentationConverter'; +import { ConvertingPatcher } from '../../../../src/storage/patch/ConvertingPatcher'; +import type { + RepresentationPatcher, + RepresentationPatcherInput, +} from '../../../../src/storage/patch/RepresentationPatcher'; + +describe('A ConvertingPatcher', (): void => { + const intermediateType = 'internal/quads'; + const defaultType = 'text/turtle'; + const identifier: ResourceIdentifier = { path: 'http://test.com/foo' }; + const patch: Patch = new BasicRepresentation([], 'type/patch'); + const representation = new BasicRepresentation([], 'application/trig'); + const patchResult = new BasicRepresentation([], 'internal/quads'); + let args: RepresentationPatcherInput; + let converter: jest.Mocked; + let patcher: jest.Mocked; + let convertingPatcher: ConvertingPatcher; + + beforeEach(async(): Promise => { + args = { patch, identifier, representation }; + + converter = { + canHandle: jest.fn(), + handle: jest.fn(async({ preferences }: RepresentationConverterArgs): Promise => + new BasicRepresentation('converted', Object.keys(preferences.type!)[0])), + } as any; + + patcher = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(patchResult), + } as any; + + convertingPatcher = new ConvertingPatcher(patcher, converter, intermediateType, defaultType); + }); + + it('rejects requests the converter cannot handle.', async(): Promise => { + converter.canHandle.mockRejectedValueOnce(new Error('unsupported type')); + await expect(convertingPatcher.canHandle(args)).rejects.toThrow('unsupported type'); + }); + + it('checks if the patcher can handle the input if there is no representation.', async(): Promise => { + delete args.representation; + + await expect(convertingPatcher.canHandle(args)).resolves.toBeUndefined(); + + patcher.canHandle.mockRejectedValueOnce(new Error('unsupported patch')); + await expect(convertingPatcher.canHandle(args)).rejects.toThrow('unsupported patch'); + }); + + it('sends a mock representation with the correct type to the patcher to check support.', async(): Promise => { + await expect(convertingPatcher.canHandle(args)).resolves.toBeUndefined(); + expect(patcher.canHandle).toHaveBeenCalledTimes(1); + expect(patcher.canHandle.mock.calls[0][0].representation?.metadata.contentType).toBe(intermediateType); + }); + + it('converts the representation before calling the patcher.', async(): Promise => { + const result = await convertingPatcher.handle(args); + expect(result.metadata.contentType).toBe('application/trig'); + + // Convert input + expect(converter.handle).toHaveBeenCalledTimes(2); + expect(converter.handle).toHaveBeenCalledWith({ + representation, + identifier, + preferences: { type: { [intermediateType]: 1 }}, + }); + + // Patch + expect(patcher.handle).toHaveBeenCalledTimes(1); + expect(patcher.handle) + .toHaveBeenLastCalledWith({ ...args, representation: await converter.handle.mock.results[0].value }); + + // Convert back + expect(converter.handle).toHaveBeenLastCalledWith({ + representation: patchResult, + identifier, + preferences: { type: { 'application/trig': 1 }}, + }); + }); + + it('expects the patcher to create a new representation if there is none.', async(): Promise => { + delete args.representation; + + const result = await convertingPatcher.handle(args); + expect(result.metadata.contentType).toBe(defaultType); + + // Patch + expect(patcher.handle).toHaveBeenCalledTimes(1); + expect(patcher.handle).toHaveBeenLastCalledWith(args); + + // Convert new Representation to default type + expect(converter.handle).toHaveBeenCalledTimes(1); + expect(converter.handle).toHaveBeenLastCalledWith({ + representation: patchResult, + identifier, + preferences: { type: { [defaultType]: 1 }}, + }); + }); + + it('does no conversion if there is no intermediate type.', async(): Promise => { + convertingPatcher = new ConvertingPatcher(patcher, converter); + const result = await convertingPatcher.handle(args); + expect(result.metadata.contentType).toBe(patchResult.metadata.contentType); + + // Patch + expect(converter.handle).toHaveBeenCalledTimes(0); + expect(patcher.handle).toHaveBeenCalledTimes(1); + expect(patcher.handle).toHaveBeenLastCalledWith(args); + }); + + it('does not convert to a default type if there is none.', async(): Promise => { + delete args.representation; + convertingPatcher = new ConvertingPatcher(patcher, converter); + const result = await convertingPatcher.handle(args); + expect(result.metadata.contentType).toBe(patchResult.metadata.contentType); + + // Patch + expect(converter.handle).toHaveBeenCalledTimes(0); + expect(patcher.handle).toHaveBeenCalledTimes(1); + expect(patcher.handle).toHaveBeenLastCalledWith(args); + }); +}); diff --git a/test/unit/storage/patch/RepresentationPatchHandler.test.ts b/test/unit/storage/patch/RepresentationPatchHandler.test.ts new file mode 100644 index 000000000..e6d562e16 --- /dev/null +++ b/test/unit/storage/patch/RepresentationPatchHandler.test.ts @@ -0,0 +1,63 @@ +import type { Patch } from '../../../../src/ldp/http/Patch'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler'; +import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher'; +import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler'; +import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; + +describe('A RepresentationPatchHandler', (): void => { + const identifier = { path: 'http://test.com/foo' }; + const representation = new BasicRepresentation('', 'text/turtle'); + const patch: Patch = new BasicRepresentation('', 'application/sparql-update'); + const patchResult = new BasicRepresentation('', 'application/trig'); + let input: PatchHandlerInput; + let source: jest.Mocked; + let patcher: jest.Mocked; + let handler: RepresentationPatchHandler; + + beforeEach(async(): Promise => { + source = { + getRepresentation: jest.fn().mockResolvedValue(representation), + setRepresentation: jest.fn().mockResolvedValue([ identifier ]), + } as any; + + input = { source, identifier, patch }; + + patcher = { + handleSafe: jest.fn().mockResolvedValue(patchResult), + } as any; + + handler = new RepresentationPatchHandler(patcher); + }); + + it('calls the patcher with the representation from the store.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual([ identifier ]); + + expect(patcher.handleSafe).toHaveBeenCalledTimes(1); + expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation }); + + expect(source.setRepresentation).toHaveBeenCalledTimes(1); + expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, patchResult); + }); + + it('calls the patcher with no representation if there is none.', async(): Promise => { + source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); + + await expect(handler.handle(input)).resolves.toEqual([ identifier ]); + + expect(patcher.handleSafe).toHaveBeenCalledTimes(1); + expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch }); + + expect(source.setRepresentation).toHaveBeenCalledTimes(1); + expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, patchResult); + }); + + it('errors if the store throws a non-404 error.', async(): Promise => { + const error = new BadRequestHttpError(); + source.getRepresentation.mockRejectedValueOnce(error); + + await expect(handler.handle(input)).rejects.toThrow(error); + }); +}); diff --git a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts deleted file mode 100644 index 5c215037c..000000000 --- a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import 'jest-rdf'; -import { namedNode, quad } from '@rdfjs/data-model'; -import arrayifyStream from 'arrayify-stream'; -import type { Quad } from 'rdf-js'; -import { translate } from 'sparqlalgebrajs'; -import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch'; -import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; -import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; -import type { RepresentationConverterArgs, - RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; -import { SparqlUpdatePatchHandler } from '../../../../src/storage/patch/SparqlUpdatePatchHandler'; -import type { ResourceStore } from '../../../../src/storage/ResourceStore'; -import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; -import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; - -describe('A SparqlUpdatePatchHandler', (): void => { - let converter: jest.Mocked; - let handler: SparqlUpdatePatchHandler; - let source: jest.Mocked; - let startQuads: Quad[]; - const defaultType = 'internal/not-quads'; - const identifier = { path: 'http://test.com/foo' }; - const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }'; - - beforeEach(async(): Promise => { - startQuads = [ quad( - namedNode('http://test.com/startS1'), - namedNode('http://test.com/startP1'), - namedNode('http://test.com/startO1'), - ), quad( - namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2'), - ) ]; - - converter = { - handleSafe: jest.fn(async({ representation, preferences }: RepresentationConverterArgs): Promise => - new BasicRepresentation(representation.data, Object.keys(preferences.type!)[0])), - } as any; - - source = { - getRepresentation: jest.fn(async(): Promise => new BasicRepresentation(startQuads, defaultType)), - setRepresentation: jest.fn(), - } as any; - - handler = new SparqlUpdatePatchHandler(converter, defaultType); - }); - - async function basicChecks(quads: Quad[]): Promise { - expect(source.getRepresentation).toHaveBeenCalledTimes(1); - expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, { }); - expect(converter.handleSafe).toHaveBeenCalledTimes(2); - expect(converter.handleSafe).toHaveBeenCalledWith({ - representation: await source.getRepresentation.mock.results[0].value, - identifier, - preferences: { type: { [INTERNAL_QUADS]: 1 }}, - }); - expect(converter.handleSafe).toHaveBeenLastCalledWith({ - representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }), - identifier, - preferences: { type: { [defaultType]: 1 }}, - }); - - expect(source.setRepresentation).toHaveBeenCalledTimes(1); - const setParams = source.setRepresentation.mock.calls[0]; - expect(setParams[0]).toEqual(identifier); - expect(setParams[1]).toEqual(expect.objectContaining({ - binary: true, - metadata: expect.any(RepresentationMetadata), - })); - expect(setParams[1].metadata.contentType).toEqual(defaultType); - await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads); - return true; - } - - async function handle(query: string): Promise { - const prefixedQuery = `prefix : \n${query}`; - await handler.handle({ - source, - identifier, - patch: { - algebra: translate(prefixedQuery, { quads: true }), - data: guardedStreamFrom(prefixedQuery), - metadata: new RepresentationMetadata(), - binary: true, - } as SparqlUpdatePatch, - }); - } - - it('only accepts SPARQL updates.', async(): Promise => { - const input = { source, identifier, patch: { algebra: {}} as SparqlUpdatePatch }; - await expect(handler.canHandle(input)).resolves.toBeUndefined(); - delete (input.patch as any).algebra; - await expect(handler.canHandle(input)).rejects.toThrow(NotImplementedHttpError); - }); - - it('handles NOP operations by not doing anything.', async(): Promise => { - await handle(''); - expect(source.getRepresentation).toHaveBeenCalledTimes(0); - expect(converter.handleSafe).toHaveBeenCalledTimes(0); - expect(source.setRepresentation).toHaveBeenCalledTimes(0); - }); - - it('handles INSERT DATA updates.', async(): Promise => { - await handle(fulfilledDataInsert); - expect(await basicChecks([ ...startQuads, - quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')), - quad(namedNode('http://test.com/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')), - ])).toBe(true); - }); - - it('handles DELETE DATA updates.', async(): Promise => { - await handle('DELETE DATA { :startS1 :startP1 :startO1 }'); - expect(await basicChecks( - [ quad(namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2')) ], - )).toBe(true); - }); - - it('handles DELETE WHERE updates with no variables.', async(): Promise => { - const query = 'DELETE WHERE { :startS1 :startP1 :startO1 }'; - await handle(query); - expect(await basicChecks( - [ quad(namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2')) ], - )).toBe(true); - }); - - it('handles DELETE WHERE updates with variables.', async(): Promise => { - const query = 'DELETE WHERE { :startS1 :startP1 ?o }'; - await handle(query); - expect(await basicChecks( - [ quad(namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2')) ], - )).toBe(true); - }); - - it('handles DELETE/INSERT updates with empty WHERE.', async(): Promise => { - const query = 'DELETE { :startS1 :startP1 :startO1 } INSERT { :s1 :p1 :o1 . } WHERE {}'; - await handle(query); - expect(await basicChecks([ - quad(namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2')), - quad(namedNode('http://test.com/s1'), - namedNode('http://test.com/p1'), - namedNode('http://test.com/o1')), - ])).toBe(true); - }); - - it('handles composite INSERT/DELETE updates.', async(): Promise => { - const query = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 };' + - 'DELETE WHERE { :s1 :p1 :o1 . :startS1 :startP1 :startO1 }'; - await handle(query); - expect(await basicChecks([ - quad(namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2')), - quad(namedNode('http://test.com/s2'), - namedNode('http://test.com/p2'), - namedNode('http://test.com/o2')), - ])).toBe(true); - }); - - it('handles composite DELETE/INSERT updates.', async(): Promise => { - const query = 'DELETE DATA { :s1 :p1 :o1 . :startS1 :startP1 :startO1 } ;' + - 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 }'; - await handle(query); - expect(await basicChecks([ - quad(namedNode('http://test.com/startS2'), - namedNode('http://test.com/startP2'), - namedNode('http://test.com/startO2')), - quad(namedNode('http://test.com/s1'), - namedNode('http://test.com/p1'), - namedNode('http://test.com/o1')), - quad(namedNode('http://test.com/s2'), - namedNode('http://test.com/p2'), - namedNode('http://test.com/o2')), - ])).toBe(true); - }); - - it('rejects GRAPH inserts.', async(): Promise => { - const query = 'INSERT DATA { GRAPH :graph { :s1 :p1 :o1 } }'; - await expect(handle(query)).rejects.toThrow(NotImplementedHttpError); - }); - - it('rejects GRAPH deletes.', async(): Promise => { - const query = 'DELETE DATA { GRAPH :graph { :s1 :p1 :o1 } }'; - await expect(handle(query)).rejects.toThrow(NotImplementedHttpError); - }); - - it('rejects DELETE/INSERT updates with non-BGP WHERE.', async(): Promise => { - const query = 'DELETE { :s1 :p1 :o1 } INSERT { :s1 :p1 :o1 } WHERE { ?s ?p ?o. FILTER (?o > 5) }'; - await expect(handle(query)).rejects.toThrow(NotImplementedHttpError); - }); - - it('rejects INSERT WHERE updates with a UNION.', async(): Promise => { - const query = 'INSERT { :s1 :p1 :o1 . } WHERE { { :s1 :p1 :o1 } UNION { :s1 :p1 :o2 } }'; - await expect(handle(query)).rejects.toThrow(NotImplementedHttpError); - }); - - it('rejects non-DELETE/INSERT updates.', async(): Promise => { - const query = 'MOVE DEFAULT TO GRAPH :newGraph'; - await expect(handle(query)).rejects.toThrow(NotImplementedHttpError); - }); - - it('throws the error returned by the store if there is one.', async(): Promise => { - source.getRepresentation.mockRejectedValueOnce(new Error('error')); - await expect(handle(fulfilledDataInsert)).rejects.toThrow('error'); - }); - - it('creates a new resource if it does not exist yet.', async(): Promise => { - startQuads = []; - source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); - const query = 'INSERT DATA { . }'; - await handle(query); - - expect(converter.handleSafe).toHaveBeenCalledTimes(1); - expect(converter.handleSafe).toHaveBeenLastCalledWith({ - representation: expect.objectContaining({ binary: false, metadata: expect.any(RepresentationMetadata) }), - identifier, - preferences: { type: { [defaultType]: 1 }}, - }); - - const quads = - [ quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')) ]; - expect(source.setRepresentation).toHaveBeenCalledTimes(1); - const setParams = source.setRepresentation.mock.calls[0]; - expect(setParams[1].metadata.contentType).toEqual(defaultType); - await expect(arrayifyStream(setParams[1].data)).resolves.toBeRdfIsomorphic(quads); - }); - - it('defaults to text/turtle if no default type was set.', async(): Promise => { - handler = new SparqlUpdatePatchHandler(converter); - startQuads = []; - source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); - const query = 'INSERT DATA { . }'; - await handle(query); - - expect(source.setRepresentation).toHaveBeenCalledTimes(1); - const setParams = source.setRepresentation.mock.calls[0]; - expect(setParams[1].metadata.contentType).toEqual('text/turtle'); - }); -}); diff --git a/test/unit/storage/patch/SparqlUpdatePatcher.test.ts b/test/unit/storage/patch/SparqlUpdatePatcher.test.ts new file mode 100644 index 000000000..3e0b457a2 --- /dev/null +++ b/test/unit/storage/patch/SparqlUpdatePatcher.test.ts @@ -0,0 +1,228 @@ +import 'jest-rdf'; +import { namedNode, quad } from '@rdfjs/data-model'; +import arrayifyStream from 'arrayify-stream'; +import type { Quad } from 'rdf-js'; +import { translate } from 'sparqlalgebrajs'; +import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import type { RepresentationPatcherInput } from '../../../../src/storage/patch/RepresentationPatcher'; +import { SparqlUpdatePatcher } from '../../../../src/storage/patch/SparqlUpdatePatcher'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; + +function getPatch(query: string): SparqlUpdatePatch { + const prefixedQuery = `prefix : \n${query}`; + return { + algebra: translate(prefixedQuery, { quads: true }), + data: guardedStreamFrom(prefixedQuery), + metadata: new RepresentationMetadata(), + binary: true, + }; +} + +describe('A SparqlUpdatePatcher', (): void => { + let patcher: SparqlUpdatePatcher; + let startQuads: Quad[]; + let representation: Representation; + const identifier = { path: 'http://test.com/foo' }; + const fulfilledDataInsert = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 . }'; + + beforeEach(async(): Promise => { + startQuads = [ quad( + namedNode('http://test.com/startS1'), + namedNode('http://test.com/startP1'), + namedNode('http://test.com/startO1'), + ), quad( + namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2'), + ) ]; + + representation = new BasicRepresentation(startQuads, 'internal/quads'); + + patcher = new SparqlUpdatePatcher(); + }); + + it('only accepts SPARQL updates.', async(): Promise => { + const input = { identifier, patch: { algebra: {}} as SparqlUpdatePatch }; + await expect(patcher.canHandle(input)).resolves.toBeUndefined(); + delete (input.patch as any).algebra; + await expect(patcher.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('handles NOP operations by not doing anything.', async(): Promise => { + let patch = getPatch(''); + let input: RepresentationPatcherInput = { identifier, patch, representation }; + await expect(patcher.handle(input)).resolves.toBe(representation); + + patch = getPatch(''); + input = { identifier, patch }; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toEqual([]); + }); + + it('handles INSERT DATA updates.', async(): Promise => { + const patch = getPatch(fulfilledDataInsert); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + ...startQuads, + quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')), + quad(namedNode('http://test.com/s2'), namedNode('http://test.com/p2'), namedNode('http://test.com/o2')), + ]); + }); + + it('handles DELETE DATA updates.', async(): Promise => { + const patch = getPatch('DELETE DATA { :startS1 :startP1 :startO1 }'); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2')), + ]); + }); + + it('handles DELETE WHERE updates with no variables.', async(): Promise => { + const patch = getPatch('DELETE WHERE { :startS1 :startP1 :startO1 }'); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2')), + ]); + }); + + it('handles DELETE WHERE updates with variables.', async(): Promise => { + const patch = getPatch('DELETE WHERE { :startS1 :startP1 ?o }'); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2')), + ]); + }); + + it('handles DELETE/INSERT updates with empty WHERE.', async(): Promise => { + const patch = getPatch('DELETE { :startS1 :startP1 :startO1 } INSERT { :s1 :p1 :o1 . } WHERE {}'); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2')), + quad(namedNode('http://test.com/s1'), + namedNode('http://test.com/p1'), + namedNode('http://test.com/o1')), + ]); + }); + + it('handles composite INSERT/DELETE updates.', async(): Promise => { + const query = 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 };' + + 'DELETE WHERE { :s1 :p1 :o1 . :startS1 :startP1 :startO1 }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2')), + quad(namedNode('http://test.com/s2'), + namedNode('http://test.com/p2'), + namedNode('http://test.com/o2')), + ]); + }); + + it('handles composite DELETE/INSERT updates.', async(): Promise => { + const query = 'DELETE DATA { :s1 :p1 :o1 . :startS1 :startP1 :startO1 } ;' + + 'INSERT DATA { :s1 :p1 :o1 . :s2 :p2 :o2 }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + const result = await patcher.handle(input); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/startS2'), + namedNode('http://test.com/startP2'), + namedNode('http://test.com/startO2')), + quad(namedNode('http://test.com/s1'), + namedNode('http://test.com/p1'), + namedNode('http://test.com/o1')), + quad(namedNode('http://test.com/s2'), + namedNode('http://test.com/p2'), + namedNode('http://test.com/o2')), + ]); + }); + + it('rejects GRAPH inserts.', async(): Promise => { + const query = 'INSERT DATA { GRAPH :graph { :s1 :p1 :o1 } }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + + await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('rejects GRAPH deletes.', async(): Promise => { + const query = 'DELETE DATA { GRAPH :graph { :s1 :p1 :o1 } }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + + await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('rejects DELETE/INSERT updates with non-BGP WHERE.', async(): Promise => { + const query = 'DELETE { :s1 :p1 :o1 } INSERT { :s1 :p1 :o1 } WHERE { ?s ?p ?o. FILTER (?o > 5) }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + + await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('rejects INSERT WHERE updates with a UNION.', async(): Promise => { + const query = 'INSERT { :s1 :p1 :o1 . } WHERE { { :s1 :p1 :o1 } UNION { :s1 :p1 :o2 } }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + + await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('rejects non-DELETE/INSERT updates.', async(): Promise => { + const query = 'MOVE DEFAULT TO GRAPH :newGraph'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch, representation }; + + await expect(patcher.handle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('creates a new resource if it does not exist yet.', async(): Promise => { + const query = 'INSERT DATA { . }'; + const patch = getPatch(query); + const input: RepresentationPatcherInput = { identifier, patch }; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + expect(result.metadata.identifier.value).toBe(identifier.path); + const resultQuads = await arrayifyStream(result.data); + expect(resultQuads).toBeRdfIsomorphic([ + quad(namedNode('http://test.com/s1'), namedNode('http://test.com/p1'), namedNode('http://test.com/o1')), + ]); + }); + + it('requires the input body to contain quads.', async(): Promise => { + const query = 'INSERT DATA { . }'; + const patch = getPatch(query); + representation.metadata.contentType = 'text/turtle'; + const input = { identifier, patch, representation }; + await expect(patcher.handle(input)).rejects.toThrow('Quad stream was expected for patching.'); + }); +});