Repository: solid/community-server (ts)

Issue:
Diamond identifiers <> are not properly interpreted when creating containers
TLDR: [this test](https://github.com/NoelDeMartin/community-server/blob/pod-creation/test/integration/ContainerCreation.test.ts#L31..L53) should pass.

Running the following requests works correctly using the default configuration:

```sh
curl 'http://localhost:3000/' -i \
    -H 'Slug: my-container' \
    -H 'Content-Type: text/turtle' \
    -H 'Link: <http://www.w3.org/ns/ldp#BasicContainer>; rel="type"' \
    --data-raw $'<> <http://www.w3.org/2000/01/rdf-schema#label> "My Container" .'
```

```sh
curl 'http://localhost:3000/my-container/' -i -H 'Accept: text/turtle'
```

However, using a file store I get the following error on the GET:

```sh
Error: Expected entity but got literal on line 1.
    at N3Parser._error (/home/noel/workspace/community-server/node_modules/n3/lib/N3Parser.js:924:17)
    at N3Parser._readEntity (/home/noel/workspace/community-server/node_modules/n3/lib/N3Parser.js:200:21)
    at N3Parser._readPredicate (/home/noel/workspace/community-server/node_modules/n3/lib/N3Parser.js:310:37)
    at /home/noel/workspace/community-server/node_modules/n3/lib/N3Parser.js:1073:127
    at N3Lexer._tokenizeToEnd (/home/noel/workspace/community-server/node_modules/n3/lib/N3Lexer.js:385:7)
    at /home/noel/workspace/community-server/node_modules/n3/lib/N3Lexer.js:515:22
    at N3StreamParser._transform (/home/noel/workspace/community-server/node_modules/n3/lib/N3StreamParser.js:46:7)
    at N3StreamParser.Transform._read (/home/noel/workspace/community-server/node_modules/readable-stream/lib/_stream_transform.js:177:10)
    at N3StreamParser.Transform._write (/home/noel/workspace/community-server/node_modules/readable-stream/lib/_stream_transform.js:164:83)
    at doWrite (/home/noel/workspace/community-server/node_modules/readable-stream/lib/_stream_writable.js:409:139)
```

I have combined the storage configuration from [config-file.json](https://github.com/solid/community-server/blob/master/config/config-file.json) and the authentication configuration from [auth-allow-all.json](https://github.com/solid/community-server/blob/master/test/configs/auth-allow-all.json) for the configuration I've used to reproduce this error.

It seems like the problem is actually on the POST, because these are the contents of the `my-container/.meta` file (It's missing the subject):

```turtle
 <http://www.w3.org/2000/01/rdf-schema#label> "My Container".
```

**Update:** I've realized memory storage is not working properly either, it doesn't throw that error but the turtle that is returned is not valid (it's missing the subject of the `schema:label` property as well).



Pull request:
fix: Take baseIRI into account when calling parseQuads
Fixes #436 . Doesn't fix all parsing issues with `<>` so #288 is still a thing.

Patch:
diff --git a/config/presets/representation-conversion.json b/config/presets/representation-conversion.json
index bb5bb0f16..bf96e0c39 100644
--- a/config/presets/representation-conversion.json
+++ b/config/presets/representation-conversion.json
@@ -37,6 +37,33 @@
       ]
     },
 
+    {
+      "@id": "urn:solid-server:default:ContentTypeReplacer",
+      "@type": "ContentTypeReplacer",
+      "ContentTypeReplacer:_replacements": [
+        {
+          "ContentTypeReplacer:_replacements_key": "application/n-triples",
+          "ContentTypeReplacer:_replacements_value": "text/turtle"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "text/turtle",
+          "ContentTypeReplacer:_replacements_value": "application/trig"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "application/ld+json",
+          "ContentTypeReplacer:_replacements_value": "application/json"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "application/*",
+          "ContentTypeReplacer:_replacements_value": "application/octet-stream"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "text/*",
+          "ContentTypeReplacer:_replacements_value": "application/octet-stream"
+        }
+      ]
+    },
+
     {
       "@id": "urn:solid-server:default:RdfRepresentationConverter",
       "@type": "ChainedConverter",
@@ -54,6 +81,9 @@
       "@id": "urn:solid-server:default:RepresentationConverter",
       "@type": "WaterfallHandler",
       "WaterfallHandler:_handlers": [
+        {
+          "@id": "urn:solid-server:default:ContentTypeReplacer"
+        },
         {
           "@id": "urn:solid-server:default:RdfToQuadConverter"
         },
diff --git a/src/index.ts b/src/index.ts
index 33da51232..36d4f3c6e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -129,6 +129,7 @@ export * from './storage/accessors/SparqlDataAccessor';
 
 // Storage/Conversion
 export * from './storage/conversion/ChainedConverter';
+export * from './storage/conversion/ContentTypeReplacer';
 export * from './storage/conversion/ConversionUtil';
 export * from './storage/conversion/QuadToRdfConverter';
 export * from './storage/conversion/RdfToQuadConverter';
diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts
index b1eb8cfb4..30771f174 100644
--- a/src/storage/DataAccessorBasedStore.ts
+++ b/src/storage/DataAccessorBasedStore.ts
@@ -97,9 +97,9 @@ export class DataAccessorBasedStore implements ResourceStore {
     // Using the parent metadata as we can also use that later to check if the nested containers maybe need to be made
     const parentMetadata = await this.getSafeNormalizedMetadata(container);
 
-    // When a POST method request targets a non-container resource without an existing representation,
+    // When a POST method request targets a resource without an existing representation,
     // the server MUST respond with the 404 status code.
-    if (!parentMetadata && !isContainerIdentifier(container)) {
+    if (!parentMetadata) {
       throw new NotFoundHttpError();
     }
 
@@ -185,7 +185,8 @@ export class DataAccessorBasedStore implements ResourceStore {
     try {
       return await this.accessor.getMetadata(identifier);
     } catch (error: unknown) {
-      if (error instanceof NotFoundHttpError) {
+      // Trimming the trailing slash of a root container is undefined as there is no parent container
+      if (error instanceof NotFoundHttpError && !this.identifierStrategy.isRootContainer(identifier)) {
         return this.accessor.getMetadata(
           { path: hasSlash ? trimTrailingSlashes(identifier.path) : ensureTrailingSlash(identifier.path) },
         );
@@ -218,6 +219,12 @@ export class DataAccessorBasedStore implements ResourceStore {
    */
   protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean,
     createContainers?: boolean): Promise<void> {
+    // Make sure the metadata has the correct identifier and correct type quads
+    // Need to do this before handling container data to have the correct identifier
+    const { metadata } = representation;
+    metadata.identifier = DataFactory.namedNode(identifier.path);
+    metadata.addQuads(generateResourceQuads(metadata.identifier, isContainer));
+
     if (isContainer) {
       await this.handleContainerData(representation);
     }
@@ -227,11 +234,6 @@ export class DataAccessorBasedStore implements ResourceStore {
       await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(identifier));
     }
 
-    // Make sure the metadata has the correct identifier and correct type quads
-    const { metadata } = representation;
-    metadata.identifier = DataFactory.namedNode(identifier.path);
-    metadata.addQuads(generateResourceQuads(metadata.identifier, isContainer));
-
     await (isContainer ?
       this.accessor.writeContainer(identifier, representation.metadata) :
       this.accessor.writeDocument(identifier, representation.data, representation.metadata));
@@ -250,7 +252,8 @@ export class DataAccessorBasedStore implements ResourceStore {
       if (representation.metadata.contentType === INTERNAL_QUADS) {
         quads = await arrayifyStream(representation.data);
       } else {
-        quads = await parseQuads(representation.data, representation.metadata.contentType);
+        const { contentType, identifier } = representation.metadata;
+        quads = await parseQuads(representation.data, { format: contentType, baseIRI: identifier.value });
       }
     } catch (error: unknown) {
       if (error instanceof Error) {
@@ -290,10 +293,10 @@ export class DataAccessorBasedStore implements ResourceStore {
    *
    * @param container - Identifier of the target container.
    * @param metadata - Metadata of the new resource.
-   * @param parentMetadata - Optional metadata of the parent container.
+   * @param parentMetadata - Metadata of the parent container.
    */
   protected createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata,
-    parentMetadata?: RepresentationMetadata): ResourceIdentifier {
+    parentMetadata: RepresentationMetadata): ResourceIdentifier {
     // Get all values needed for naming the resource
     const isContainer = this.isNewContainer(metadata);
     const slug = metadata.get(HTTP.slug)?.value;
@@ -302,14 +305,12 @@ export class DataAccessorBasedStore implements ResourceStore {
     let newID: ResourceIdentifier = this.createURI(container, isContainer, slug);
 
     // Make sure we don't already have a resource with this exact name (or with differing trailing slash)
-    if (parentMetadata) {
-      const withSlash = ensureTrailingSlash(newID.path);
-      const withoutSlash = trimTrailingSlashes(newID.path);
-      const exists = parentMetadata.getAll(LDP.contains).some((term): boolean =>
-        term.value === withSlash || term.value === withoutSlash);
-      if (exists) {
-        newID = this.createURI(container, isContainer);
-      }
+    const withSlash = ensureTrailingSlash(newID.path);
+    const withoutSlash = trimTrailingSlashes(newID.path);
+    const exists = parentMetadata.getAll(LDP.contains).some((term): boolean =>
+      term.value === withSlash || term.value === withoutSlash);
+    if (exists) {
+      newID = this.createURI(container, isContainer);
     }
 
     return newID;
diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts
index 73fee2657..0c38069c5 100644
--- a/src/storage/accessors/FileDataAccessor.ts
+++ b/src/storage/accessors/FileDataAccessor.ts
@@ -266,7 +266,7 @@ export class FileDataAccessor implements DataAccessor {
       await fsPromises.lstat(metadataLink.filePath);
 
       const readMetadataStream = guardStream(createReadStream(metadataLink.filePath));
-      return await parseQuads(readMetadataStream, metadataLink.contentType);
+      return await parseQuads(readMetadataStream, { format: metadataLink.contentType, baseIRI: identifier.path });
     } catch (error: unknown) {
       // Metadata file doesn't exist so lets keep `rawMetaData` an empty array.
       if (!isSystemError(error) || error.code !== 'ENOENT') {
diff --git a/src/storage/conversion/ContentTypeReplacer.ts b/src/storage/conversion/ContentTypeReplacer.ts
new file mode 100644
index 000000000..a92e684ec
--- /dev/null
+++ b/src/storage/conversion/ContentTypeReplacer.ts
@@ -0,0 +1,75 @@
+import type { Representation } from '../../ldp/representation/Representation';
+import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
+import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
+import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { CONTENT_TYPE } from '../../util/Vocabularies';
+import { matchesMediaType, matchingMediaTypes } from './ConversionUtil';
+import type { RepresentationConverterArgs } from './RepresentationConverter';
+import { RepresentationConverter } from './RepresentationConverter';
+
+/**
+ * A {@link RepresentationConverter} that changes the content type
+ * but does not alter the representation.
+ *
+ * Useful for when a content type is binary-compatible with another one;
+ * for instance, all JSON-LD files are valid JSON files.
+ */
+export class ContentTypeReplacer extends RepresentationConverter {
+  private readonly contentTypeMap: Record<string, ValuePreferences> = {};
+
+  /**
+   * @param replacements - Map of content type patterns and content types to replace them by.
+   */
+  public constructor(replacements: Record<string, string>);
+  public constructor(replacements: Record<string, Iterable<string>>);
+  public constructor(replacements: Record<string, any>) {
+    super();
+    // Store the replacements as value preferences,
+    // completing any transitive chains (A:B, B:C, C:D => A:B,C,D)
+    for (const inputType of Object.keys(replacements)) {
+      this.contentTypeMap[inputType] = {};
+      (function addReplacements(inType, outTypes): void {
+        const replace = replacements[inType] ?? [];
+        const newTypes = typeof replace === 'string' ? [ replace ] : replace;
+        for (const newType of newTypes) {
+          if (!(newType in outTypes)) {
+            outTypes[newType] = 1;
+            addReplacements(newType, outTypes);
+          }
+        }
+      })(inputType, this.contentTypeMap[inputType]);
+    }
+  }
+
+  public async canHandle({ representation, preferences }: RepresentationConverterArgs): Promise<void> {
+    this.getReplacementType(representation.metadata.contentType, preferences.type);
+  }
+
+  /**
+   * Changes the content type on the representation.
+   */
+  public async handle({ representation, preferences }: RepresentationConverterArgs): Promise<Representation> {
+    const contentType = this.getReplacementType(representation.metadata.contentType, preferences.type);
+    const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: contentType });
+    return { ...representation, metadata };
+  }
+
+  public async handleSafe(args: RepresentationConverterArgs): Promise<Representation> {
+    return this.handle(args);
+  }
+
+  /**
+   * Find a replacement content type that matches the preferences,
+   * or throws an error if none was found.
+   */
+  private getReplacementType(contentType = 'unknown', preferred: ValuePreferences = {}): string {
+    const supported = Object.keys(this.contentTypeMap)
+      .filter((type): boolean => matchesMediaType(contentType, type))
+      .map((type): ValuePreferences => this.contentTypeMap[type]);
+    const matching = matchingMediaTypes(preferred, Object.assign({} as ValuePreferences, ...supported));
+    if (matching.length === 0) {
+      throw new NotImplementedHttpError(`Cannot convert from ${contentType} to ${Object.keys(preferred)}`);
+    }
+    return matching[0];
+  }
+}
diff --git a/src/util/QuadUtil.ts b/src/util/QuadUtil.ts
index 1a1299c5c..3ae742669 100644
--- a/src/util/QuadUtil.ts
+++ b/src/util/QuadUtil.ts
@@ -1,5 +1,6 @@
 import type { Readable, PassThrough } from 'stream';
 import arrayifyStream from 'arrayify-stream';
+import type { ParserOptions } from 'n3';
 import { DataFactory, StreamParser, StreamWriter } from 'n3';
 import type { Literal, NamedNode, Quad } from 'rdf-js';
 import streamifyArray from 'streamify-array';
@@ -33,10 +34,10 @@ export function serializeQuads(quads: Quad[], contentType?: string): Guarded<Rea
 /**
  * Helper function to convert a Readable into an array of quads.
  * @param readable - The readable object.
- * @param contentType - The content-type of the stream.
+ * @param options - Options for the parser.
  *
  * @returns A promise containing the array of quads.
  */
-export async function parseQuads(readable: Guarded<Readable>, contentType?: string): Promise<Quad[]> {
-  return arrayifyStream(pipeSafely(readable, new StreamParser({ format: contentType })));
+export async function parseQuads(readable: Guarded<Readable>, options: ParserOptions = {}): Promise<Quad[]> {
+  return arrayifyStream(pipeSafely(readable, new StreamParser(options)));
 }


Test patch:
diff --git a/test/integration/LdpHandlerWithoutAuth.test.ts b/test/integration/LdpHandlerWithoutAuth.test.ts
index a4c34001f..f9168ad20 100644
--- a/test/integration/LdpHandlerWithoutAuth.test.ts
+++ b/test/integration/LdpHandlerWithoutAuth.test.ts
@@ -1,7 +1,11 @@
+import { DataFactory, Parser } from 'n3';
+import type { MockResponse } from 'node-mocks-http';
+import { ensureTrailingSlash } from '../../src/';
 import type { HttpHandler, Initializer, ResourceStore } from '../../src/';
 import { LDP } from '../../src/util/Vocabularies';
 import { ResourceHelper } from '../util/TestHelpers';
 import { BASE, getTestFolder, createFolder, removeFolder, instantiateFromConfig } from './Config';
+const { literal, namedNode, quad } = DataFactory;
 
 const rootFilePath = getTestFolder('full-config-no-auth');
 const stores: [string, any][] = [
@@ -229,4 +233,34 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     await resourceHelper.deleteResource(fileId);
     await resourceHelper.shouldNotExist(fileId);
   });
+
+  it('can create a container with a diamond identifier in the data.', async(): Promise<void> => {
+    const slug = 'my-container';
+
+    let response: MockResponse<any> = await resourceHelper.performRequestWithBody(
+      new URL(ensureTrailingSlash(BASE)),
+      'POST',
+      {
+        'content-type': 'text/turtle',
+        'transfer-encoding': 'chunked',
+        link: '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
+        slug,
+      },
+      Buffer.from('<> <http://www.w3.org/2000/01/rdf-schema#label> "My Container" .', 'utf-8'),
+    );
+
+    expect(response.statusCode).toBe(201);
+    expect(response._getHeaders().location).toBe(`${BASE}/${slug}/`);
+
+    response = await resourceHelper.performRequest(new URL(`${BASE}/${slug}/`), 'GET', { accept: 'text/turtle' });
+    expect(response.statusCode).toBe(200);
+
+    const parser = new Parser();
+    const quads = parser.parse(response._getData());
+    expect(quads.some((entry): boolean => entry.equals(quad(
+      namedNode(`${BASE}/${slug}/`),
+      namedNode('http://www.w3.org/2000/01/rdf-schema#label'),
+      literal('My Container'),
+    )))).toBeTruthy();
+  });
 });
diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts
index 5f3d2746f..ba463be00 100644
--- a/test/unit/storage/DataAccessorBasedStore.test.ts
+++ b/test/unit/storage/DataAccessorBasedStore.test.ts
@@ -1,6 +1,7 @@
 import 'jest-rdf';
 import type { Readable } from 'stream';
 import arrayifyStream from 'arrayify-stream';
+import type { Quad } from 'n3';
 import { DataFactory } from 'n3';
 import type { Representation } from '../../../src/ldp/representation/Representation';
 import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
@@ -137,8 +138,8 @@ describe('A DataAccessorBasedStore', (): void => {
       await expect(store.addResource(resourceID, representation)).rejects.toThrow(BadRequestHttpError);
     });
 
-    it('will 404 if the target does not exist and does not end in a slash.', async(): Promise<void> => {
-      const resourceID = { path: `${root}container` };
+    it('will 404 if the target does not exist.', async(): Promise<void> => {
+      const resourceID = { path: `${root}container/` };
       await expect(store.addResource(resourceID, representation)).rejects.toThrow(NotFoundHttpError);
     });
 
@@ -187,13 +188,18 @@ describe('A DataAccessorBasedStore', (): void => {
       const resourceID = { path: root };
       representation.metadata.add(RDF.type, LDP.terms.Container);
       representation.metadata.contentType = 'text/turtle';
-      representation.data = guardedStreamFrom([ `<${`${root}resource/`}> a <coolContainer>.` ]);
+      representation.data = guardedStreamFrom([ '<> a <http://test.com/coolContainer>.' ]);
       const result = await store.addResource(resourceID, representation);
       expect(result).toEqual({
         path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
       });
       expect(accessor.data[result.path]).toBeTruthy();
       expect(accessor.data[result.path].metadata.contentType).toBeUndefined();
+
+      const { data } = await store.getRepresentation(result);
+      const quads: Quad[] = await arrayifyStream(data);
+      expect(quads.some((entry): boolean => entry.subject.value === result.path &&
+        entry.object.value === 'http://test.com/coolContainer')).toBeTruthy();
     });
 
     it('creates a URI based on the incoming slug.', async(): Promise<void> => {
@@ -219,27 +225,6 @@ describe('A DataAccessorBasedStore', (): void => {
         path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
       });
     });
-
-    it('creates recursive containers when needed.', async(): Promise<void> => {
-      const resourceID = { path: `${root}a/b/` };
-      const result = await store.addResource(resourceID, representation);
-      expect(result).toEqual({
-        path: expect.stringMatching(new RegExp(`^${root}a/b/[^/]+$`, 'u')),
-      });
-      await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]);
-      expect(accessor.data[`${root}a/`].metadata.getAll(RDF.type).map((type): string => type.value))
-        .toContain(LDP.Container);
-      expect(accessor.data[`${root}a/b/`].metadata.getAll(RDF.type).map((type): string => type.value))
-        .toContain(LDP.Container);
-    });
-
-    it('errors when a recursive container overlaps with an existing resource.', async(): Promise<void> => {
-      const resourceID = { path: `${root}a/b/` };
-      accessor.data[`${root}a`] = representation;
-      await expect(store.addResource(resourceID, representation)).rejects.toThrow(
-        new ConflictHttpError(`Creating container ${root}a/ conflicts with an existing resource.`),
-      );
-    });
   });
 
   describe('setting a Representation', (): void => {
@@ -262,6 +247,27 @@ describe('A DataAccessorBasedStore', (): void => {
         .rejects.toThrow(`${resourceID.path} conflicts with existing path ${resourceID.path}/`);
     });
 
+    // As discussed in #475, trimming the trailing slash of a root container in getNormalizedMetadata
+    // can result in undefined behaviour since there is no parent container.
+    it('will not trim the slash of root containers since there is no parent.', async(): Promise<void> => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete accessor.data[root];
+
+      const mock = jest.spyOn(accessor, 'getMetadata');
+
+      const resourceID = { path: `${root}` };
+      representation.metadata.removeAll(RDF.type);
+      representation.metadata.contentType = 'text/turtle';
+      representation.data = guardedStreamFrom([ `<${`${root}`}> a <coolContainer>.` ]);
+
+      await expect(store.setRepresentation(resourceID, representation))
+        .resolves.toBeUndefined();
+      expect(mock).toHaveBeenCalledTimes(1);
+      expect(mock).toHaveBeenLastCalledWith(resourceID);
+
+      mock.mockRestore();
+    });
+
     it('will error if the target has a different resource type.', async(): Promise<void> => {
       const resourceID = { path: `${root}resource` };
       accessor.data[resourceID.path] = representation;
diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts
index a317492ea..de5b44cc3 100644
--- a/test/unit/storage/accessors/FileDataAccessor.test.ts
+++ b/test/unit/storage/accessors/FileDataAccessor.test.ts
@@ -128,13 +128,13 @@ describe('A FileDataAccessor', (): void => {
     });
 
     it('adds stored metadata when requesting metadata.', async(): Promise<void> => {
-      cache.data = { resource: 'data', 'resource.meta': '<this> <is> <metadata>.' };
+      cache.data = { resource: 'data', 'resource.meta': '<http://this> <http://is> <http://metadata>.' };
       metadata = await accessor.getMetadata({ path: `${base}resource` });
-      expect(metadata.quads().some((quad): boolean => quad.subject.value === 'this')).toBe(true);
+      expect(metadata.quads().some((quad): boolean => quad.subject.value === 'http://this')).toBe(true);
 
-      cache.data = { container: { '.meta': '<this> <is> <metadata>.' }};
+      cache.data = { container: { '.meta': '<http://this> <http://is> <http://metadata>.' }};
       metadata = await accessor.getMetadata({ path: `${base}container/` });
-      expect(metadata.quads().some((quad): boolean => quad.subject.value === 'this')).toBe(true);
+      expect(metadata.quads().some((quad): boolean => quad.subject.value === 'http://this')).toBe(true);
     });
 
     it('throws an error if there is a problem with the internal metadata.', async(): Promise<void> => {
diff --git a/test/unit/storage/conversion/ContentTypeReplacer.test.ts b/test/unit/storage/conversion/ContentTypeReplacer.test.ts
new file mode 100644
index 000000000..f35a27801
--- /dev/null
+++ b/test/unit/storage/conversion/ContentTypeReplacer.test.ts
@@ -0,0 +1,97 @@
+import 'jest-rdf';
+import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
+import { ContentTypeReplacer } from '../../../../src/storage/conversion/ContentTypeReplacer';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+
+const binary = true;
+const data = { data: true };
+
+describe('A ContentTypeReplacer', (): void => {
+  const converter = new ContentTypeReplacer({
+    'application/n-triples': [
+      'text/turtle',
+      'application/trig',
+      'application/n-quads',
+    ],
+    'application/ld+json': 'application/json',
+    'application/json': 'application/octet-stream',
+    'application/octet-stream': 'internal/anything',
+    'internal/anything': 'application/octet-stream',
+    '*/*': 'application/octet-stream',
+  });
+
+  it('throws on an unsupported input type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'text/plain' });
+    const representation = { metadata };
+    const preferences = { type: { 'application/json': 1 }};
+
+    await expect(converter.canHandle({ representation, preferences } as any))
+      .rejects.toThrow(new Error('Cannot convert from text/plain to application/json'));
+  });
+
+  it('throws on an unsupported output type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { metadata };
+    const preferences = { type: { 'application/json': 1 }};
+
+    await expect(converter.canHandle({ representation, preferences } as any))
+      .rejects.toThrow(new Error('Cannot convert from application/n-triples to application/json'));
+  });
+
+  it('does not replace when no content type is given.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata();
+    const representation = { binary, data, metadata };
+    const preferences = { type: { 'application/json': 1 }};
+
+    await expect(converter.canHandle({ representation, preferences } as any))
+      .rejects.toThrow(new NotImplementedHttpError('Cannot convert from unknown to application/json'));
+  });
+
+  it('replaces a supported content type when no preferences are given.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { binary, data, metadata };
+    const preferences = {};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('text/turtle');
+  });
+
+  it('replaces a supported content type when preferences are given.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { binary, data, metadata };
+    const preferences = { type: { 'application/n-quads': 1 }};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('application/n-quads');
+  });
+
+  it('replaces a supported wildcard type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'text/plain' });
+    const representation = { binary, data, metadata };
+    const preferences = { type: { 'application/octet-stream': 1 }};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('application/octet-stream');
+  });
+
+  it('picks the most preferred content type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { binary, data, metadata };
+    const preferences = { type: {
+      'text/turtle': 0.5,
+      'application/trig': 0.6,
+      'application/n-quads': 0.4,
+    }};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('application/trig');
+  });
+});
diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts
index 49a35b844..54a1406b8 100644
--- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts
+++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts
@@ -13,7 +13,7 @@ import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
 import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
 import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
 
-describe('A RdfToQuadConverter.test.ts', (): void => {
+describe('A RdfToQuadConverter', (): void => {
   const converter = new RdfToQuadConverter();
   const identifier: ResourceIdentifier = { path: 'path' };
 
diff --git a/test/unit/util/QuadUtil.test.ts b/test/unit/util/QuadUtil.test.ts
index 914751026..d1e8d03b0 100644
--- a/test/unit/util/QuadUtil.test.ts
+++ b/test/unit/util/QuadUtil.test.ts
@@ -36,9 +36,18 @@ describe('QuadUtil', (): void => {
   });
 
   describe('#parseQuads', (): void => {
-    it('parses quads from the requested format.', async(): Promise<void> => {
+    it('parses quads.', async(): Promise<void> => {
       const stream = guardedStreamFrom([ '<pre:sub> <pre:pred> "obj".' ]);
-      await expect(parseQuads(stream, 'application/n-triples')).resolves.toEqualRdfQuadArray([ quad(
+      await expect(parseQuads(stream)).resolves.toEqualRdfQuadArray([ quad(
+        namedNode('pre:sub'),
+        namedNode('pre:pred'),
+        literal('obj'),
+      ) ]);
+    });
+
+    it('parses quads with the given options.', async(): Promise<void> => {
+      const stream = guardedStreamFrom([ '<> <pre:pred> "obj".' ]);
+      await expect(parseQuads(stream, { baseIRI: 'pre:sub' })).resolves.toEqualRdfQuadArray([ quad(
         namedNode('pre:sub'),
         namedNode('pre:pred'),
         literal('obj'),