mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support SPARQL store backends
This commit is contained in:
@@ -1,450 +0,0 @@
|
||||
import { Readable } from 'stream';
|
||||
import { namedNode, triple } from '@rdfjs/data-model';
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { fetch } from 'cross-fetch';
|
||||
import { DataFactory } from 'n3';
|
||||
import streamifyArray from 'streamify-array';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { QuadRepresentation } from '../../../src/ldp/representation/QuadRepresentation';
|
||||
import type { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
|
||||
import { SparqlResourceStore } from '../../../src/storage/SparqlResourceStore';
|
||||
import { UrlContainerManager } from '../../../src/storage/UrlContainerManager';
|
||||
import {
|
||||
CONTENT_TYPE_QUADS,
|
||||
DATA_TYPE_BINARY,
|
||||
DATA_TYPE_QUAD,
|
||||
} from '../../../src/util/ContentTypes';
|
||||
import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError';
|
||||
import { MethodNotAllowedHttpError } from '../../../src/util/errors/MethodNotAllowedHttpError';
|
||||
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
|
||||
import { UnsupportedMediaTypeHttpError } from '../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
||||
import { InteractionController } from '../../../src/util/InteractionController';
|
||||
import { LINK_TYPE_LDP_BC, LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../../../src/util/LinkTypes';
|
||||
import { CONTAINS_PREDICATE } from '../../../src/util/MetadataController';
|
||||
import { ResourceStoreController } from '../../../src/util/ResourceStoreController';
|
||||
|
||||
const base = 'http://test.com/';
|
||||
const sparqlEndpoint = 'http://localhost:8889/bigdata/sparql';
|
||||
|
||||
jest.mock('cross-fetch');
|
||||
jest.mock('uuid');
|
||||
|
||||
describe('A SparqlResourceStore', (): void => {
|
||||
let store: SparqlResourceStore;
|
||||
let representation: QuadRepresentation;
|
||||
let spyOnSparqlResourceType: jest.SpyInstance<any, unknown[]>;
|
||||
|
||||
const quad = triple(
|
||||
namedNode('http://test.com/s'),
|
||||
namedNode('http://test.com/p'),
|
||||
namedNode('http://test.com/o'),
|
||||
);
|
||||
|
||||
const metadata = [ triple(
|
||||
namedNode('http://test.com/container'),
|
||||
CONTAINS_PREDICATE,
|
||||
namedNode('http://test.com/resource'),
|
||||
) ];
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
store = new SparqlResourceStore(base, sparqlEndpoint, new ResourceStoreController(base,
|
||||
new InteractionController()), new UrlContainerManager(base));
|
||||
|
||||
representation = {
|
||||
data: streamifyArray([ quad ]),
|
||||
dataType: DATA_TYPE_QUAD,
|
||||
metadata: { raw: [], linkRel: { type: new Set() }} as RepresentationMetadata,
|
||||
};
|
||||
|
||||
spyOnSparqlResourceType = jest.spyOn(store as any, `getSparqlResourceType`);
|
||||
(uuid as jest.Mock).mockReturnValue('rand-om-st-ring');
|
||||
});
|
||||
|
||||
/**
|
||||
* Create the mocked return values for the getSparqlResourceType function.
|
||||
* @param isContainer - Whether the mock should imitate a container.
|
||||
* @param isResource - Whether the mock should imitate a resource.
|
||||
*/
|
||||
const mockResourceType = function(isContainer: boolean, isResource: boolean): void {
|
||||
let jsonResult: any;
|
||||
if (isContainer) {
|
||||
jsonResult = { results: { bindings: [{ type: { type: 'uri', value: LINK_TYPE_LDPC }}]}};
|
||||
} else if (isResource) {
|
||||
jsonResult = { results: { bindings: [{ type: { type: 'uri', value: LINK_TYPE_LDPR }}]}};
|
||||
}
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => jsonResult } as
|
||||
unknown as Response);
|
||||
};
|
||||
|
||||
it('errors if a resource was not found.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
mockResourceType(false, false);
|
||||
const jsonResult = { results: { bindings: [{ type: { type: 'uri', value: 'unknown' }}]}};
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => jsonResult } as
|
||||
unknown as Response);
|
||||
|
||||
// Tests
|
||||
await expect(store.getRepresentation({ path: `${base}wrong` })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.addResource({ path: 'http://wrong.com/wrong' }, representation))
|
||||
.rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.deleteResource({ path: 'wrong' })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.deleteResource({ path: `${base}wrong` })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.setRepresentation({ path: 'http://wrong.com/' }, representation))
|
||||
.rejects.toThrow(NotFoundHttpError);
|
||||
});
|
||||
|
||||
it('(passes the SPARQL query to the endpoint for a PATCH request) errors for modifyResource.',
|
||||
async(): Promise<void> => {
|
||||
await expect(store.modifyResource()).rejects.toThrow(Error);
|
||||
|
||||
// Temporary test to get the 100% coverage for already implemented but unused behaviour in sendSparqlUpdate,
|
||||
// because an error is thrown for now.
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
const sparql = 'INSERT DATA { GRAPH <https://example.com/foo/> { <https://example.com/foo/> <http://www.w3.org/ns/ldp#contains> <https://example.com/foo/.metadata>. } }';
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(await store.sendSparqlUpdate(sparql)).toBeUndefined();
|
||||
|
||||
// // Mock the cross-fetch functions.
|
||||
// (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
//
|
||||
// // Tests
|
||||
// const sparql = 'INSERT DATA { GRAPH <https://example.com/foo/> { <https://example.com/foo/> <http://www.w3.org/ns/ldp#contains> <https://example.com/foo/.metadata>. } }';
|
||||
// const algebra = translate(sparql, { quads: true });
|
||||
// const patch = {
|
||||
// algebra,
|
||||
// dataType: DATA_TYPE_BINARY,
|
||||
// data: Readable.from(sparql),
|
||||
// metadata: {
|
||||
// raw: [],
|
||||
// profiles: [],
|
||||
// contentType: CONTENT_TYPE_SPARQL_UPDATE,
|
||||
// },
|
||||
// };
|
||||
// await store.modifyResource({ path: `${base}foo` }, patch);
|
||||
// const init = {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': CONTENT_TYPE_SPARQL_UPDATE,
|
||||
// },
|
||||
// body: sparql,
|
||||
// };
|
||||
// expect(fetch as jest.Mock).toBeCalledWith(new Request(sparqlEndpoint), init);
|
||||
// expect(fetch as jest.Mock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('errors for wrong input data types.', async(): Promise<void> => {
|
||||
(representation as any).dataType = DATA_TYPE_BINARY;
|
||||
await expect(store.addResource({ path: base }, representation)).rejects.toThrow(UnsupportedMediaTypeHttpError);
|
||||
await expect(store.setRepresentation({ path: `${base}foo` }, representation)).rejects
|
||||
.toThrow(UnsupportedMediaTypeHttpError);
|
||||
|
||||
// This has not yet been fully implemented correctly.
|
||||
// const patch = {
|
||||
// dataType: DATA_TYPE_QUAD,
|
||||
// data: streamifyArray([ quad ]),
|
||||
// metadata: {
|
||||
// raw: [],
|
||||
// profiles: [],
|
||||
// contentType: CONTENT_TYPE_QUADS,
|
||||
// },
|
||||
// };
|
||||
// await expect(store.modifyResource({ path: `${base}foo` }, patch)).rejects.toThrow(UnsupportedMediaTypeHttpError);
|
||||
});
|
||||
|
||||
it('can write and read data.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
// Add
|
||||
mockResourceType(true, false);
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Mock: Get
|
||||
mockResourceType(false, true);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: [ quad ]}}) } as
|
||||
unknown as Response);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: metadata }}) } as
|
||||
unknown as Response);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []};
|
||||
const identifier = await store.addResource({ path: `${base}foo/` }, representation);
|
||||
expect(identifier.path).toBe(`${base}foo/rand-om-st-ring`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}foo/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path);
|
||||
|
||||
const result = await store.getRepresentation(identifier);
|
||||
expect(result).toEqual({
|
||||
dataType: representation.dataType,
|
||||
data: expect.any(Readable),
|
||||
metadata: {
|
||||
raw: metadata,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
});
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(3);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(6);
|
||||
await expect(arrayifyStream(result.data)).resolves.toEqual([ quad ]);
|
||||
});
|
||||
|
||||
it('errors for container creation with path to non container.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
mockResourceType(false, true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []};
|
||||
await expect(store.addResource({ path: `${base}foo` }, representation)).rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}foo/`);
|
||||
});
|
||||
|
||||
it('errors 405 for POST invalid path ending without slash.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
mockResourceType(false, false);
|
||||
mockResourceType(false, false);
|
||||
mockResourceType(false, true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []};
|
||||
await expect(store.addResource({ path: `${base}doesnotexist` }, representation))
|
||||
.rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexist/`);
|
||||
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []};
|
||||
await expect(store.addResource({ path: `${base}doesnotexist` }, representation))
|
||||
.rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexist/`);
|
||||
|
||||
representation.metadata = { linkRel: { type: new Set() }, slug: 'file.txt', raw: []};
|
||||
await expect(store.addResource({ path: `${base}existingresource` }, representation))
|
||||
.rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}existingresource/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(3);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
it('can write and read a container.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
// Add
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Mock: Get
|
||||
mockResourceType(true, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: [ quad ]}}) } as
|
||||
unknown as Response);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: metadata }}) } as
|
||||
unknown as Response);
|
||||
|
||||
// Write container (POST)
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: metadata };
|
||||
const identifier = await store.addResource({ path: base }, representation);
|
||||
expect(identifier.path).toBe(`${base}myContainer/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}myContainer/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(2);
|
||||
|
||||
// Read container
|
||||
const result = await store.getRepresentation(identifier);
|
||||
expect(result).toEqual({
|
||||
dataType: representation.dataType,
|
||||
data: expect.any(Readable),
|
||||
metadata: {
|
||||
raw: metadata,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
});
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(2);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(5);
|
||||
await expect(arrayifyStream(result.data)).resolves.toEqual([ quad, ...metadata ]);
|
||||
});
|
||||
|
||||
it('can set data.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
const spyOnCreateResource = jest.spyOn(store as any, `createResource`);
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
await store.setRepresentation({ path: `${base}file.txt` }, representation);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(spyOnCreateResource).toBeCalledWith(`${base}file.txt`, [ quad ], []);
|
||||
expect(spyOnCreateResource).toBeCalledTimes(1);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('can delete data.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
// Delete
|
||||
const spyOnDeleteSparqlDocument = jest.spyOn(store as any, `deleteSparqlDocument`);
|
||||
mockResourceType(false, true);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Mock: Get
|
||||
mockResourceType(false, false);
|
||||
|
||||
// Tests
|
||||
await store.deleteResource({ path: `${base}file.txt` });
|
||||
expect(spyOnDeleteSparqlDocument).toBeCalledWith(`${base}file.txt`);
|
||||
expect(spyOnDeleteSparqlDocument).toBeCalledTimes(1);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`);
|
||||
|
||||
await expect(store.getRepresentation({ path: `${base}file.txt` })).rejects.toThrow(NotFoundHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('creates intermediate container when POSTing resource to path ending with slash.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
const spyOnCreateContainer = jest.spyOn(store as any, `createContainer`);
|
||||
const spyOnCreateResource = jest.spyOn(store as any, `createResource`);
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []};
|
||||
const identifier = await store.addResource({ path: `${base}doesnotexistyet/` }, representation);
|
||||
expect(identifier.path).toBe(`${base}doesnotexistyet/file.txt`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexistyet/`);
|
||||
expect(spyOnCreateContainer).toBeCalledWith(`${base}doesnotexistyet/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexistyet/file.txt`);
|
||||
expect(spyOnCreateResource).toBeCalledWith(`${base}doesnotexistyet/file.txt`, [ quad ], []);
|
||||
expect(spyOnCreateContainer).toBeCalledTimes(1);
|
||||
expect(spyOnCreateResource).toBeCalledTimes(1);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(2);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
it('errors when deleting root container.', async(): Promise<void> => {
|
||||
// Tests
|
||||
await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError);
|
||||
});
|
||||
|
||||
it('errors when deleting non empty container.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
const spyOnIsEmptyContainer = jest.spyOn(store as any, `isEmptyContainer`);
|
||||
mockResourceType(true, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ boolean: true }) } as
|
||||
unknown as Response);
|
||||
|
||||
// Tests
|
||||
await expect(store.deleteResource({ path: `${base}notempty/` })).rejects.toThrow(ConflictHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}notempty/`);
|
||||
expect(spyOnIsEmptyContainer).toBeCalledWith(`${base}notempty/`);
|
||||
});
|
||||
|
||||
it('can overwrite representation with PUT.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
const spyOnCreateResource = jest.spyOn(store as any, `createResource`);
|
||||
mockResourceType(false, true);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []};
|
||||
await store.setRepresentation({ path: `${base}alreadyexists.txt` }, representation);
|
||||
expect(spyOnCreateResource).toBeCalledWith(`${base}alreadyexists.txt`, [ quad ], []);
|
||||
expect(spyOnCreateResource).toBeCalledTimes(1);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('errors when overwriting container with PUT.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
mockResourceType(true, false);
|
||||
mockResourceType(false, true);
|
||||
mockResourceType(true, false);
|
||||
|
||||
// Tests
|
||||
await expect(store.setRepresentation({ path: `${base}alreadyexists` }, representation)).rejects
|
||||
.toThrow(ConflictHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists`);
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []};
|
||||
await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects
|
||||
.toThrow(ConflictHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists/`);
|
||||
await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects
|
||||
.toThrow(ConflictHttpError);
|
||||
expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(3);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
it('can overwrite container metadata with POST.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
const spyOnOverwriteContainerMetadata = jest.spyOn(store as any, `overwriteContainerMetadata`);
|
||||
mockResourceType(true, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) },
|
||||
raw: metadata,
|
||||
slug: 'alreadyexists/' };
|
||||
await store.addResource({ path: base }, representation);
|
||||
expect(spyOnOverwriteContainerMetadata).toBeCalledWith(`${base}alreadyexists/`, metadata);
|
||||
expect(spyOnOverwriteContainerMetadata).toBeCalledTimes(1);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('can delete empty container.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
const spyOnDeleteSparqlContainer = jest.spyOn(store as any, `deleteSparqlContainer`);
|
||||
mockResourceType(true, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ boolean: false }) } as
|
||||
unknown as Response);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
await store.deleteResource({ path: `${base}foo/` });
|
||||
expect(spyOnDeleteSparqlContainer).toBeCalledWith(`${base}foo/`);
|
||||
expect(spyOnDeleteSparqlContainer).toBeCalledTimes(1);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
it('errors when passing quads not in the default graph.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
mockResourceType(false, false);
|
||||
|
||||
// Tests
|
||||
const namedGraphQuad = DataFactory.quad(
|
||||
namedNode('http://test.com/s'),
|
||||
namedNode('http://test.com/p'),
|
||||
namedNode('http://test.com/o'),
|
||||
namedNode('http://test.com/g'),
|
||||
);
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []};
|
||||
representation.data = streamifyArray([ namedGraphQuad ]);
|
||||
await expect(store.addResource({ path: base }, representation)).rejects.toThrow(ConflictHttpError);
|
||||
});
|
||||
|
||||
it('errors when getting bad response from server.', async(): Promise<void> => {
|
||||
// Mock the cross-fetch functions.
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 400 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []};
|
||||
await expect(store.setRepresentation({ path: `${base}foo.txt` }, representation)).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
it('creates container with random UUID when POSTing without slug header.', async(): Promise<void> => {
|
||||
// Mock the uuid and cross-fetch functions.
|
||||
mockResourceType(false, false);
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []};
|
||||
const identifier = await store.addResource({ path: base }, representation);
|
||||
expect(identifier.path).toBe(`${base}rand-om-st-ring/`);
|
||||
expect(spyOnSparqlResourceType).toBeCalledTimes(1);
|
||||
expect(fetch as jest.Mock).toBeCalledTimes(2);
|
||||
expect(uuid as jest.Mock).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
208
test/unit/storage/accessors/SparqlDataAccessor.test.ts
Normal file
208
test/unit/storage/accessors/SparqlDataAccessor.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { Readable } from 'stream';
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { SparqlEndpointFetcher } from 'fetch-sparql-endpoint';
|
||||
import { DataFactory } from 'n3';
|
||||
import type { Quad } from 'rdf-js';
|
||||
import streamifyArray from 'streamify-array';
|
||||
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
|
||||
import { SparqlDataAccessor } from '../../../../src/storage/accessors/SparqlDataAccessor';
|
||||
import { UrlContainerManager } from '../../../../src/storage/UrlContainerManager';
|
||||
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
|
||||
import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError';
|
||||
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
|
||||
import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
|
||||
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
||||
import { MetadataController } from '../../../../src/util/MetadataController';
|
||||
import { CONTENT_TYPE, LDP, RDF } from '../../../../src/util/UriConstants';
|
||||
import { toNamedNode } from '../../../../src/util/UriUtil';
|
||||
|
||||
const { literal, namedNode, quad } = DataFactory;
|
||||
|
||||
jest.mock('fetch-sparql-endpoint');
|
||||
|
||||
const simplifyQuery = (query: string | string[]): string => {
|
||||
if (Array.isArray(query)) {
|
||||
query = query.join(' ');
|
||||
}
|
||||
return query.replace(/\n/gu, ' ').trim();
|
||||
};
|
||||
|
||||
describe('A SparqlDataAccessor', (): void => {
|
||||
const endpoint = 'http://test.com/sparql';
|
||||
const base = 'http://test.com/';
|
||||
let accessor: SparqlDataAccessor;
|
||||
let metadata: RepresentationMetadata;
|
||||
let fetchTriples: jest.Mock<Promise<Readable>>;
|
||||
let fetchUpdate: jest.Mock<Promise<void>>;
|
||||
let triples: Quad[];
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata();
|
||||
triples = [ quad(namedNode('this'), namedNode('a'), namedNode('triple')) ];
|
||||
|
||||
// Makes it so the `SparqlEndpointFetcher` will always return the contents of the `bindings` array
|
||||
fetchTriples = jest.fn(async(): Promise<Readable> => streamifyArray(triples));
|
||||
fetchUpdate = jest.fn(async(): Promise<void> => undefined);
|
||||
(SparqlEndpointFetcher as any).mockImplementation((): any => ({
|
||||
fetchTriples,
|
||||
fetchUpdate,
|
||||
}));
|
||||
|
||||
// This needs to be last so the fetcher can be mocked first
|
||||
accessor = new SparqlDataAccessor(endpoint, base, new UrlContainerManager(base), new MetadataController());
|
||||
});
|
||||
|
||||
it('can only handle quad data.', async(): Promise<void> => {
|
||||
const data = streamifyArray([]);
|
||||
await expect(accessor.canHandle({ binary: true, data, metadata })).rejects.toThrow(UnsupportedMediaTypeHttpError);
|
||||
metadata.contentType = 'newInternalType';
|
||||
await expect(accessor.canHandle({ binary: false, data, metadata })).rejects.toThrow(UnsupportedMediaTypeHttpError);
|
||||
metadata.contentType = INTERNAL_QUADS;
|
||||
await expect(accessor.canHandle({ binary: false, data, metadata })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the corresponding quads when data is requested.', async(): Promise<void> => {
|
||||
const data = await accessor.getData({ path: 'http://identifier' });
|
||||
await expect(arrayifyStream(data)).resolves.toBeRdfIsomorphic([
|
||||
quad(namedNode('this'), namedNode('a'), namedNode('triple')),
|
||||
]);
|
||||
|
||||
expect(fetchTriples).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery(
|
||||
'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH <http://identifier> { ?s ?p ?o. } }',
|
||||
));
|
||||
});
|
||||
|
||||
it('returns the corresponding metadata when requested.', async(): Promise<void> => {
|
||||
metadata = await accessor.getMetadata({ path: 'http://identifier' });
|
||||
expect(metadata.quads()).toBeRdfIsomorphic([
|
||||
quad(namedNode('this'), namedNode('a'), namedNode('triple')),
|
||||
quad(namedNode('http://identifier'), toNamedNode(CONTENT_TYPE), literal(INTERNAL_QUADS)),
|
||||
]);
|
||||
|
||||
expect(fetchTriples).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery(
|
||||
'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH <meta:http://identifier> { ?s ?p ?o. } }',
|
||||
));
|
||||
});
|
||||
|
||||
it('requests container data for generating its metadata.', async(): Promise<void> => {
|
||||
metadata = await accessor.getMetadata({ path: 'http://container/' });
|
||||
expect(metadata.quads()).toBeRdfIsomorphic([
|
||||
quad(namedNode('this'), namedNode('a'), namedNode('triple')),
|
||||
quad(namedNode('http://container/'), toNamedNode(CONTENT_TYPE), literal(INTERNAL_QUADS)),
|
||||
]);
|
||||
|
||||
expect(fetchTriples).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery([
|
||||
'CONSTRUCT { ?s ?p ?o. } WHERE {',
|
||||
' { GRAPH <http://container/> { ?s ?p ?o. } }',
|
||||
' UNION',
|
||||
' { GRAPH <meta:http://container/> { ?s ?p ?o. } }',
|
||||
'}',
|
||||
]));
|
||||
});
|
||||
|
||||
it('generates resource metadata for the root container.', async(): Promise<void> => {
|
||||
metadata = await accessor.getMetadata({ path: base });
|
||||
expect(metadata.quads()).toBeRdfIsomorphic([
|
||||
quad(namedNode('this'), namedNode('a'), namedNode('triple')),
|
||||
quad(namedNode(base), toNamedNode(CONTENT_TYPE), literal(INTERNAL_QUADS)),
|
||||
quad(namedNode(base), toNamedNode(RDF.type), toNamedNode(LDP.Container)),
|
||||
quad(namedNode(base), toNamedNode(RDF.type), toNamedNode(LDP.BasicContainer)),
|
||||
quad(namedNode(base), toNamedNode(RDF.type), toNamedNode(LDP.Resource)),
|
||||
]);
|
||||
|
||||
expect(fetchTriples).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery([
|
||||
'CONSTRUCT { ?s ?p ?o. } WHERE {',
|
||||
` { GRAPH <${base}> { ?s ?p ?o. } }`,
|
||||
' UNION',
|
||||
` { GRAPH <meta:${base}> { ?s ?p ?o. } }`,
|
||||
'}',
|
||||
]));
|
||||
});
|
||||
|
||||
it('throws 404 if no metadata was found.', async(): Promise<void> => {
|
||||
// Clear bindings array
|
||||
triples.splice(0, triples.length);
|
||||
await expect(accessor.getMetadata({ path: 'http://identifier' })).rejects.toThrow(NotFoundHttpError);
|
||||
|
||||
expect(fetchTriples).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTriples.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery(
|
||||
'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH <meta:http://identifier> { ?s ?p ?o. } }',
|
||||
));
|
||||
});
|
||||
|
||||
it('overwrites the metadata when writing a container and updates parent.', async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata('http://test.com/container/',
|
||||
{ [RDF.type]: [ toNamedNode(LDP.Resource), toNamedNode(LDP.Container) ]});
|
||||
await expect(accessor.writeContainer({ path: 'http://test.com/container/' }, metadata)).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([
|
||||
'DELETE WHERE { GRAPH <meta:http://test.com/container/> { ?s ?p ?o. } };',
|
||||
'INSERT DATA {',
|
||||
' GRAPH <meta:http://test.com/container/> {',
|
||||
' <http://test.com/container/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Resource>.',
|
||||
' <http://test.com/container/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Container>.',
|
||||
' }',
|
||||
' GRAPH <http://test.com/> { <http://test.com/> <http://www.w3.org/ns/ldp#contains> <http://test.com/container/>. }',
|
||||
'}',
|
||||
]));
|
||||
});
|
||||
|
||||
it('overwrites the data and metadata when writing a resource and updates parent.', async(): Promise<void> => {
|
||||
const data = streamifyArray([ quad(namedNode('http://name'), namedNode('http://pred'), literal('value')) ]);
|
||||
metadata = new RepresentationMetadata('http://test.com/container/resource',
|
||||
{ [RDF.type]: [ toNamedNode(LDP.Resource) ]});
|
||||
await expect(accessor.writeDocument({ path: 'http://test.com/container/resource' }, data, metadata))
|
||||
.resolves.toBeUndefined();
|
||||
|
||||
expect(fetchUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([
|
||||
'DELETE WHERE { GRAPH <http://test.com/container/resource> { ?s ?p ?o. } };',
|
||||
'DELETE WHERE { GRAPH <meta:http://test.com/container/resource> { ?s ?p ?o. } };',
|
||||
'INSERT DATA {',
|
||||
' GRAPH <meta:http://test.com/container/resource> { <http://test.com/container/resource> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Resource>. }',
|
||||
' GRAPH <http://test.com/container/> { <http://test.com/container/> <http://www.w3.org/ns/ldp#contains> <http://test.com/container/resource>. }',
|
||||
' GRAPH <http://test.com/container/resource> { <http://name> <http://pred> "value". }',
|
||||
'}',
|
||||
]));
|
||||
});
|
||||
|
||||
it('removes all references when deleting a resource.', async(): Promise<void> => {
|
||||
metadata = new RepresentationMetadata('http://test.com/container/',
|
||||
{ [RDF.type]: [ toNamedNode(LDP.Resource), toNamedNode(LDP.Container) ]});
|
||||
await expect(accessor.deleteResource({ path: 'http://test.com/container/' })).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint);
|
||||
expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([
|
||||
'DELETE WHERE { GRAPH <http://test.com/container/> { ?s ?p ?o. } };',
|
||||
'DELETE WHERE { GRAPH <meta:http://test.com/container/> { ?s ?p ?o. } };',
|
||||
'DELETE DATA { GRAPH <http://test.com/> { <http://test.com/> <http://www.w3.org/ns/ldp#contains> <http://test.com/container/>. } }',
|
||||
]));
|
||||
});
|
||||
|
||||
it('errors when trying to write to a metadata document.', async(): Promise<void> => {
|
||||
const data = streamifyArray([ quad(namedNode('http://name'), namedNode('http://pred'), literal('value')) ]);
|
||||
await expect(accessor.writeDocument({ path: 'meta:http://test.com/container/resource' }, data, metadata))
|
||||
.rejects.toThrow(new ConflictHttpError('Not allowed to create NamedNodes with the metadata extension.'));
|
||||
});
|
||||
|
||||
it('errors when writing triples in a non-default graph.', async(): Promise<void> => {
|
||||
const data = streamifyArray(
|
||||
[ quad(namedNode('http://name'), namedNode('http://pred'), literal('value'), namedNode('badGraph!')) ],
|
||||
);
|
||||
await expect(accessor.writeDocument({ path: 'http://test.com/container/resource' }, data, metadata))
|
||||
.rejects.toThrow(new UnsupportedHttpError('Only triples in the default graph are supported.'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user