feat: Keep track of last modified date of resources

This commit is contained in:
Joachim Van Herwegen
2021-08-13 16:48:44 +02:00
parent 47b3a2d77f
commit 97c534b2bf
6 changed files with 130 additions and 50 deletions

View File

@@ -21,7 +21,7 @@ import type { Guarded } from '../../../src/util/GuardedStream';
import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy';
import { trimTrailingSlashes } from '../../../src/util/PathUtil';
import { guardedStreamFrom } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META } from '../../../src/util/Vocabularies';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC } from '../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
class SimpleDataAccessor implements DataAccessor {
@@ -117,6 +117,9 @@ class SimpleSuffixStrategy implements AuxiliaryStrategy {
}
describe('A DataAccessorBasedStore', (): void => {
const now = new Date(1234567489);
const later = new Date(987654321);
let mockDate: jest.SpyInstance;
let store: DataAccessorBasedStore;
let accessor: SimpleDataAccessor;
const root = 'http://test.com/';
@@ -127,6 +130,8 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceData = 'text';
beforeEach(async(): Promise<void> => {
mockDate = jest.spyOn(global, 'Date').mockReturnValue(now as any);
accessor = new SimpleDataAccessor();
auxiliaryStrategy = new SimpleSuffixStrategy('.dummy');
@@ -242,6 +247,7 @@ describe('A DataAccessorBasedStore', (): void => {
path: expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
});
await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[result.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
});
it('can write containers.', async(): Promise<void> => {
@@ -256,8 +262,9 @@ describe('A DataAccessorBasedStore', (): void => {
expect(accessor.data[result.path]).toBeTruthy();
expect(accessor.data[result.path].metadata.contentType).toBeUndefined();
const { data } = await store.getRepresentation(result);
const { data, metadata } = await store.getRepresentation(result);
const quads: Quad[] = await arrayifyStream(data);
expect(metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(quads.some((entry): boolean => entry.subject.value === result.path &&
entry.object.value === 'http://test.com/coolContainer')).toBeTruthy();
});
@@ -345,7 +352,7 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: `${root}` };
representation.metadata.removeAll(RDF.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${`${root}`}> a <coolContainer>.` ]);
representation.data = guardedStreamFrom([ `<${root}> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves
.toEqual([{ path: `${root}` }]);
@@ -382,6 +389,8 @@ describe('A DataAccessorBasedStore', (): void => {
{ path: `${root}resource` },
]);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
});
it('can write containers.', async(): Promise<void> => {
@@ -390,13 +399,37 @@ describe('A DataAccessorBasedStore', (): void => {
// Generate based on URI
representation.metadata.removeAll(RDF.type);
representation.metadata.contentType = 'text/turtle';
representation.data = guardedStreamFrom([ `<${`${root}resource/`}> a <coolContainer>.` ]);
representation.data = guardedStreamFrom([ `<${root}resource/> a <coolContainer>.` ]);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}container/` },
]);
expect(accessor.data[resourceID.path]).toBeTruthy();
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
});
it('can overwrite resources which does not update parent metadata.', async(): Promise<void> => {
const resourceID = { path: `${root}resource` };
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: root },
{ path: `${root}resource` },
]);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
// Parent metadata does not get updated if the resource already exists
representation = new BasicRepresentation('updatedText', 'text/plain');
mockDate.mockReturnValue(later);
await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([
{ path: `${root}resource` },
]);
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ 'updatedText' ]);
expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(later.toISOString());
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
mockDate.mockReturnValue(now);
});
it('does not write generated metadata.', async(): Promise<void> => {
@@ -446,7 +479,7 @@ describe('A DataAccessorBasedStore', (): void => {
representation.metadata.contentType = 'text/turtle';
representation.metadata.identifier = DataFactory.namedNode(`${root}resource/`);
representation.data = guardedStreamFrom(
[ `<${`${root}resource/`}> <http://www.w3.org/ns/ldp#contains> <uri>.` ],
[ `<${root}resource/> <http://www.w3.org/ns/ldp#contains> <uri>.` ],
);
const result = store.setRepresentation(resourceID, representation);
await expect(result).rejects.toThrow(ConflictHttpError);
@@ -540,8 +573,18 @@ describe('A DataAccessorBasedStore', (): void => {
accessor.data[`${root}resource`] = representation;
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
{ path: root },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString());
});
it('will delete root non-storage containers.', async(): Promise<void> => {
accessor.data[root] = new BasicRepresentation(representation.data, containerMetadata);
await expect(store.deleteResource({ path: root })).resolves.toEqual([
{ path: root },
]);
expect(accessor.data[root]).toBeUndefined();
});
it('will delete a root storage auxiliary resource of a non-root container.', async(): Promise<void> => {
@@ -551,6 +594,7 @@ describe('A DataAccessorBasedStore', (): void => {
auxiliaryStrategy.isRootRequired = jest.fn().mockReturnValue(true);
await expect(store.deleteResource({ path: `${root}container/.dummy` })).resolves.toEqual([
{ path: `${root}container/.dummy` },
{ path: `${root}container/` },
]);
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined();
});
@@ -561,6 +605,7 @@ describe('A DataAccessorBasedStore', (): void => {
await expect(store.deleteResource({ path: `${root}container/` })).resolves.toEqual([
{ path: `${root}container/` },
{ path: `${root}container/.dummy` },
{ path: root },
]);
expect(accessor.data[`${root}container/`]).toBeUndefined();
expect(accessor.data[`${root}container/.dummy`]).toBeUndefined();
@@ -580,6 +625,7 @@ describe('A DataAccessorBasedStore', (): void => {
logger.error = jest.fn();
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
{ path: root },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
expect(accessor.data[`${root}resource.dummy`]).not.toBeUndefined();
@@ -588,29 +634,6 @@ describe('A DataAccessorBasedStore', (): void => {
'Error deleting auxiliary resource http://test.com/resource.dummy: auxiliary error!',
);
});
it('can also handle auxiliary deletion to throw non-native errors.', async(): Promise<void> => {
accessor.data[`${root}resource`] = representation;
accessor.data[`${root}resource.dummy`] = representation;
const deleteFn = accessor.deleteResource;
accessor.deleteResource = jest.fn(async(identifier: ResourceIdentifier): Promise<void> => {
if (auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) {
throw 'auxiliary error!';
}
await deleteFn.call(accessor, identifier);
});
const { logger } = store as any;
logger.error = jest.fn();
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([
{ path: `${root}resource` },
]);
expect(accessor.data[`${root}resource`]).toBeUndefined();
expect(accessor.data[`${root}resource.dummy`]).not.toBeUndefined();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith(
'Error deleting auxiliary resource http://test.com/resource.dummy: Unknown error: auxiliary error!',
);
});
});
describe('resource Exists', (): void => {

View File

@@ -21,6 +21,8 @@ jest.mock('fs');
const rootFilePath = 'uploads';
const now = new Date();
// All relevant functions do not care about the milliseconds or remove them
now.setMilliseconds(0);
describe('A FileDataAccessor', (): void => {
const base = 'http://test.com/';
@@ -103,7 +105,8 @@ describe('A FileDataAccessor', (): void => {
expect(metadata.get(POSIX.size)).toEqualRdfTerm(toLiteral('data'.length, XSD.terms.integer));
expect(metadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
expect(metadata.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), XSD.terms.integer));
expect(metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata)).toHaveLength(3);
// `dc:modified` is in the default graph
expect(metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata)).toHaveLength(2);
});
it('does not generate size metadata for a container.', async(): Promise<void> => {
@@ -123,7 +126,8 @@ describe('A FileDataAccessor', (): void => {
expect(metadata.get(POSIX.size)).toBeUndefined();
expect(metadata.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
expect(metadata.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), XSD.terms.integer));
expect(metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata)).toHaveLength(2);
// `dc:modified` is in the default graph
expect(metadata.quads(null, null, null, SOLID_META.terms.ResponseMetadata)).toHaveLength(1);
});
it('generates metadata for container child resources.', async(): Promise<void> => {
@@ -139,8 +143,9 @@ describe('A FileDataAccessor', (): void => {
expect(child.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime));
expect(child.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000),
XSD.terms.integer));
// `dc:modified` is in the default graph
expect(child.quads(null, null, null, SOLID_META.terms.ResponseMetadata))
.toHaveLength(isContainerPath(child.identifier.value) ? 2 : 3);
.toHaveLength(isContainerPath(child.identifier.value) ? 1 : 2);
}
});

View File

@@ -1,7 +1,10 @@
import 'jest-rdf';
import type { Literal } from 'n3';
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../src/ldp/representation/Representation';
import * as resourceUtils from '../../../src/util/ResourceUtil';
import 'jest-rdf';
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
import { cloneRepresentation, updateModifiedDate } from '../../../src/util/ResourceUtil';
import { DC, XSD } from '../../../src/util/Vocabularies';
describe('ResourceUtil', (): void => {
let representation: Representation;
@@ -10,16 +13,33 @@ describe('ResourceUtil', (): void => {
representation = new BasicRepresentation('data', 'metadata');
});
describe('cloneRepresentation', (): void => {
describe('#updateModifiedDate', (): void => {
it('adds the given date without milliseconds as last modified date.', async(): Promise<void> => {
const date = new Date();
date.setMilliseconds(500);
const metadata = new RepresentationMetadata();
updateModifiedDate(metadata, date);
const lastModified = metadata.get(DC.terms.modified);
expect(lastModified?.termType).toBe('Literal');
const lastModifiedDate = new Date(lastModified!.value);
expect(date.getTime() - lastModifiedDate.getTime()).toBe(500);
date.setMilliseconds(0);
expect(lastModified?.value).toBe(date.toISOString());
expect((lastModified as Literal).datatype).toEqualRdfTerm(XSD.terms.dateTime);
});
});
describe('#cloneRepresentation', (): void => {
it('returns a clone of the passed representation.', async(): Promise<void> => {
const res = await resourceUtils.cloneRepresentation(representation);
const res = await cloneRepresentation(representation);
expect(res.binary).toBe(representation.binary);
expect(res.metadata.identifier).toBe(representation.metadata.identifier);
expect(res.metadata.contentType).toBe(representation.metadata.contentType);
});
it('ensures that original representation does not update when the clone is updated.', async(): Promise<void> => {
const res = await resourceUtils.cloneRepresentation(representation);
const res = await cloneRepresentation(representation);
res.metadata.contentType = 'typetype';
expect(representation.metadata.contentType).not.toBe(res.metadata.contentType);
});