Repository: solid/community-server
Pull request #520 (2021-01-16 21:50:52)

Add IfNeededConverter and PassthroughConverter
This simplifies the `RepresentationConvertingStore` by:
- moving out the decision logic of whether to convert
- ensuring there is always a converter, such that it does not need to be called conditionally

This also makes is possible to reuse the decision logic in other contexts where we will need content negotiation, such as for example error responses.

Note that usage of `IfNeededConverter` in the default configuration might become unnecessary with #484.


Patch:
diff --git a/config/presets/representation-conversion.json b/config/presets/representation-conversion.json
index 50c2ca579..1029e828d 100644
--- a/config/presets/representation-conversion.json
+++ b/config/presets/representation-conversion.json
@@ -79,21 +79,24 @@
 
     {
       "@id": "urn:solid-server:default:RepresentationConverter",
-      "@type": "WaterfallHandler",
-      "WaterfallHandler:_handlers": [
-        {
-          "@id": "urn:solid-server:default:ContentTypeReplacer"
-        },
-        {
-          "@id": "urn:solid-server:default:RdfToQuadConverter"
-        },
-        {
-          "@id": "urn:solid-server:default:QuadToRdfConverter"
-        },
-        {
-          "@id": "urn:solid-server:default:RdfRepresentationConverter"
-        }
-      ]
+      "@type": "IfNeededConverter",
+      "IfNeededConverter:_converter": {
+        "@type": "WaterfallHandler",
+        "WaterfallHandler:_handlers": [
+          {
+            "@id": "urn:solid-server:default:ContentTypeReplacer"
+          },
+          {
+            "@id": "urn:solid-server:default:RdfToQuadConverter"
+          },
+          {
+            "@id": "urn:solid-server:default:QuadToRdfConverter"
+          },
+          {
+            "@id": "urn:solid-server:default:RdfRepresentationConverter"
+          }
+        ]
+      }
     }
   ]
 }
diff --git a/src/index.ts b/src/index.ts
index 92b17fdde..185f02166 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -132,6 +132,8 @@ export * from './storage/accessors/SparqlDataAccessor';
 // Storage/Conversion
 export * from './storage/conversion/ChainedConverter';
 export * from './storage/conversion/ContentTypeReplacer';
+export * from './storage/conversion/IfNeededConverter';
+export * from './storage/conversion/PassthroughConverter';
 export * from './storage/conversion/ConversionUtil';
 export * from './storage/conversion/QuadToRdfConverter';
 export * from './storage/conversion/RdfToQuadConverter';
diff --git a/src/storage/RepresentationConvertingStore.ts b/src/storage/RepresentationConvertingStore.ts
index b912ae38e..763ca3e8d 100644
--- a/src/storage/RepresentationConvertingStore.ts
+++ b/src/storage/RepresentationConvertingStore.ts
@@ -2,35 +2,21 @@ import type { Representation } from '../ldp/representation/Representation';
 import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
 import { getLoggerFor } from '../logging/LogUtil';
-import { InternalServerError } from '../util/errors/InternalServerError';
 import type { Conditions } from './Conditions';
-import { matchingMediaTypes } from './conversion/ConversionUtil';
-import type { RepresentationConverter, RepresentationConverterArgs } from './conversion/RepresentationConverter';
+import { PassthroughConverter } from './conversion/PassthroughConverter';
+import type { RepresentationConverter } from './conversion/RepresentationConverter';
 import { PassthroughStore } from './PassthroughStore';
 import type { ResourceStore } from './ResourceStore';
 
 /**
- * Store that overrides all functions that take or output a {@link Representation},
- * so `getRepresentation`, `addResource`, and `setRepresentation`.
- *
- * For incoming representations, they will be converted if an incoming converter and preferences have been set.
- * The converted Representation will be passed along.
- *
- * For outgoing representations, they will be converted if there is an outgoing converter.
- *
- * Conversions will only happen if required and will not happen if the Representation is already in the correct format.
- *
- * In the future this class should take the preferences of the request into account.
- * Even if there is a match with the output from the store,
- * if there is a low weight for that type conversions might still be preferred.
+ * Store that provides (optional) conversion of incoming and outgoing {@link Representation}s.
  */
 export class RepresentationConvertingStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
   protected readonly logger = getLoggerFor(this);
 
-  private readonly inConverter?: RepresentationConverter;
-  private readonly outConverter?: RepresentationConverter;
-
-  private readonly inType?: string;
+  private readonly inConverter: RepresentationConverter;
+  private readonly outConverter: RepresentationConverter;
+  private readonly inPreferences: RepresentationPreferences;
 
   /**
    * TODO: This should take RepresentationPreferences instead of a type string when supported by Components.js.
@@ -41,74 +27,29 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
     inType?: string;
   }) {
     super(source);
-    this.inConverter = options.inConverter;
-    this.outConverter = options.outConverter;
-    this.inType = options.inType;
+    const { inConverter, outConverter, inType } = options;
+    this.inConverter = inConverter ?? new PassthroughConverter();
+    this.outConverter = outConverter ?? new PassthroughConverter();
+    this.inPreferences = !inType ? {} : { type: { [inType]: 1 }};
   }
 
   public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
     conditions?: Conditions): Promise<Representation> {
     const representation = await super.getRepresentation(identifier, preferences, conditions);
-    return this.convertRepresentation({ identifier, representation, preferences }, this.outConverter);
+    return this.outConverter.handleSafe({ identifier, representation, preferences });
   }
 
-  public async addResource(container: ResourceIdentifier, representation: Representation,
+  public async addResource(identifier: ResourceIdentifier, representation: Representation,
     conditions?: Conditions): Promise<ResourceIdentifier> {
     // We can potentially run into problems here if we convert a turtle document where the base IRI is required,
     // since we don't know the resource IRI yet at this point.
-    representation = await this.convertInRepresentation(container, representation);
-    return this.source.addResource(container, representation, conditions);
+    representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
+    return this.source.addResource(identifier, representation, conditions);
   }
 
   public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
     conditions?: Conditions): Promise<void> {
-    representation = await this.convertInRepresentation(identifier, representation);
+    representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
     return this.source.setRepresentation(identifier, representation, conditions);
   }
-
-  /**
-   * Helper function that checks if the given representation matches the given preferences.
-   */
-  private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean {
-    const { contentType } = representation.metadata;
-
-    if (!contentType) {
-      throw new InternalServerError('Content-Type is required for data conversion.');
-    }
-
-    // Check if there is a result if we try to map the preferences to the content-type
-    return matchingMediaTypes(preferences.type, { [contentType]: 1 }).length > 0;
-  }
-
-  /**
-   * Helper function that converts a Representation using the given args and converter,
-   * if the conversion is necessary and there is a converter.
-   */
-  private async convertRepresentation(input: RepresentationConverterArgs, converter?: RepresentationConverter):
-  Promise<Representation> {
-    if (!converter || !input.preferences.type || this.matchesPreferences(input.representation, input.preferences)) {
-      return input.representation;
-    }
-    this.logger.debug(`Conversion needed for ${input.identifier
-      .path} from ${input.representation.metadata.contentType} to satisfy ${Object.entries(input.preferences.type)
-      .map(([ value, weight ]): string => `${value};q=${weight}`).join(', ')}`);
-
-    const converted = await converter.handleSafe(input);
-    this.logger.info(`Converted representation for ${input.identifier
-      .path} from ${input.representation.metadata.contentType} to ${converted.metadata.contentType}`);
-    return converted;
-  }
-
-  /**
-   * Helper function that converts an incoming representation if necessary.
-   */
-  private async convertInRepresentation(identifier: ResourceIdentifier, representation: Representation):
-  Promise<Representation> {
-    if (!this.inType) {
-      return representation;
-    }
-    const preferences: RepresentationPreferences = { type: { [this.inType]: 1 }};
-
-    return this.convertRepresentation({ identifier, representation, preferences }, this.inConverter);
-  }
 }
diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts
new file mode 100644
index 000000000..535f447d9
--- /dev/null
+++ b/src/storage/conversion/IfNeededConverter.ts
@@ -0,0 +1,61 @@
+import type { Representation } from '../../ldp/representation/Representation';
+import { getLoggerFor } from '../../logging/LogUtil';
+import { InternalServerError } from '../../util/errors/InternalServerError';
+import { matchingMediaTypes } from './ConversionUtil';
+import { RepresentationConverter } from './RepresentationConverter';
+import type { RepresentationConverterArgs } from './RepresentationConverter';
+
+/**
+ * A {@link RepresentationConverter} that only converts representations
+ * that are not compatible with the preferences.
+ */
+export class IfNeededConverter extends RepresentationConverter {
+  private readonly converter: RepresentationConverter;
+  protected readonly logger = getLoggerFor(this);
+
+  public constructor(converter: RepresentationConverter) {
+    super();
+    this.converter = converter;
+  }
+
+  public async canHandle(args: RepresentationConverterArgs): Promise<void> {
+    if (this.needsConversion(args)) {
+      await this.converter.canHandle(args);
+    }
+  }
+
+  public async handle(args: RepresentationConverterArgs): Promise<Representation> {
+    return !this.needsConversion(args) ? args.representation : this.convert(args, false);
+  }
+
+  public async handleSafe(args: RepresentationConverterArgs): Promise<Representation> {
+    return !this.needsConversion(args) ? args.representation : this.convert(args, true);
+  }
+
+  protected needsConversion({ identifier, representation, preferences }: RepresentationConverterArgs): boolean {
+    // No conversion needed if no preferences were specified
+    if (!preferences.type) {
+      return false;
+    }
+
+    // No conversion is needed if there are any matches for the provided content type
+    const { contentType } = representation.metadata;
+    if (!contentType) {
+      throw new InternalServerError('Content-Type is required for data conversion.');
+    }
+    const noMatchingMediaType = matchingMediaTypes(preferences.type, { [contentType]: 1 }).length === 0;
+    if (noMatchingMediaType) {
+      this.logger.debug(`Conversion needed for ${identifier
+        .path} from ${representation.metadata.contentType} to satisfy ${Object.entries(preferences.type)
+        .map(([ value, weight ]): string => `${value};q=${weight}`).join(', ')}`);
+    }
+    return noMatchingMediaType;
+  }
+
+  protected async convert(args: RepresentationConverterArgs, safely: boolean): Promise<Representation> {
+    const converted = await (safely ? this.converter.handleSafe(args) : this.converter.handle(args));
+    this.logger.info(`Converted representation for ${args.identifier
+      .path} from ${args.representation.metadata.contentType} to ${converted.metadata.contentType}`);
+    return converted;
+  }
+}
diff --git a/src/storage/conversion/PassthroughConverter.ts b/src/storage/conversion/PassthroughConverter.ts
new file mode 100644
index 000000000..0dbaca78b
--- /dev/null
+++ b/src/storage/conversion/PassthroughConverter.ts
@@ -0,0 +1,12 @@
+import type { Representation } from '../../ldp/representation/Representation';
+import { RepresentationConverter } from './RepresentationConverter';
+import type { RepresentationConverterArgs } from './RepresentationConverter';
+
+/**
+ * A {@link RepresentationConverter} that does not perform any conversion.
+ */
+export class PassthroughConverter extends RepresentationConverter {
+  public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
+    return representation;
+  }
+}


Test patch:
diff --git a/test/unit/storage/RepresentationConvertingStore.test.ts b/test/unit/storage/RepresentationConvertingStore.test.ts
index 7b023b407..e3174a9ee 100644
--- a/test/unit/storage/RepresentationConvertingStore.test.ts
+++ b/test/unit/storage/RepresentationConvertingStore.test.ts
@@ -1,111 +1,70 @@
 import type { Representation } from '../../../src/ldp/representation/Representation';
-import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
 import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter';
 import { RepresentationConvertingStore } from '../../../src/storage/RepresentationConvertingStore';
 import type { ResourceStore } from '../../../src/storage/ResourceStore';
-import { InternalServerError } from '../../../src/util/errors/InternalServerError';
-import { CONTENT_TYPE } from '../../../src/util/Vocabularies';
 
 describe('A RepresentationConvertingStore', (): void => {
-  let store: RepresentationConvertingStore;
-  let source: ResourceStore;
-  let inConverter: RepresentationConverter;
-  let outConverter: RepresentationConverter;
-  const convertedIn = { metadata: {}};
-  const convertedOut = { metadata: {}};
+  const identifier = { path: 'identifier' };
+  const metadata = { contentType: 'text/turtle' };
+  const representation: Representation = { binary: true, data: 'data', metadata } as any;
+  const preferences = { type: { 'text/plain': 1, 'text/turtle': 0 }};
+
+  const sourceRepresentation = { data: 'data' };
+  const source: ResourceStore = {
+    getRepresentation: jest.fn().mockResolvedValue(sourceRepresentation),
+    addResource: jest.fn(),
+    setRepresentation: jest.fn(),
+  } as any;
+
+  const convertedIn = { in: true };
+  const convertedOut = { out: true };
+  const inConverter: RepresentationConverter = { handleSafe: jest.fn().mockResolvedValue(convertedIn) } as any;
+  const outConverter: RepresentationConverter = { handleSafe: jest.fn().mockResolvedValue(convertedOut) } as any;
+
   const inType = 'text/turtle';
-  const metadata = new RepresentationMetadata('text/turtle');
-  let representation: Representation;
+  const store = new RepresentationConvertingStore(source, { inType, inConverter, outConverter });
 
   beforeEach(async(): Promise<void> => {
-    source = {
-      getRepresentation: jest.fn(async(): Promise<any> => ({ data: 'data', metadata })),
-      addResource: jest.fn(),
-      setRepresentation: jest.fn(),
-    } as any;
-
-    inConverter = { handleSafe: jest.fn(async(): Promise<any> => convertedIn) } as any;
-    outConverter = { handleSafe: jest.fn(async(): Promise<any> => convertedOut) } as any;
-
-    store = new RepresentationConvertingStore(source, { inType, inConverter, outConverter });
-    representation = { binary: true, data: 'data', metadata } as any;
-  });
-
-  it('returns the Representation from the source if no changes are required.', async(): Promise<void> => {
-    const result = await store.getRepresentation({ path: 'path' },
-      { type: { 'application/*': 0, 'text/turtle': 1 }});
-    expect(result).toEqual({
-      data: 'data',
-      metadata: expect.any(RepresentationMetadata),
-    });
-    expect(result.metadata.contentType).toEqual('text/turtle');
-    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
-    expect(source.getRepresentation).toHaveBeenLastCalledWith(
-      { path: 'path' },
-      { type: { 'application/*': 0, 'text/turtle': 1 }},
-      undefined,
-    );
-    expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
-  });
-
-  it('returns the Representation from the source if there are no preferences.', async(): Promise<void> => {
-    const result = await store.getRepresentation({ path: 'path' }, {});
-    expect(result).toEqual({
-      data: 'data',
-      metadata: expect.any(RepresentationMetadata),
-    });
-    expect(result.metadata.contentType).toEqual('text/turtle');
-    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
-    expect(source.getRepresentation).toHaveBeenLastCalledWith(
-      { path: 'path' }, {}, undefined,
-    );
-    expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
+    jest.clearAllMocks();
   });
 
-  it('calls the converter if another output is preferred.', async(): Promise<void> => {
-    await expect(store.getRepresentation({ path: 'path' },
-      { type: { 'text/plain': 1, 'text/turtle': 0 }})).resolves.toEqual(convertedOut);
+  it('calls the outgoing converter when retrieving a representation.', async(): Promise<void> => {
+    await expect(store.getRepresentation(identifier, preferences)).resolves.toEqual(convertedOut);
     expect(source.getRepresentation).toHaveBeenCalledTimes(1);
     expect(outConverter.handleSafe).toHaveBeenCalledTimes(1);
-    expect(outConverter.handleSafe).toHaveBeenLastCalledWith({
-      identifier: { path: 'path' },
-      representation: { data: 'data', metadata },
-      preferences: { type: { 'text/plain': 1, 'text/turtle': 0 }},
+    expect(outConverter.handleSafe).toHaveBeenNthCalledWith(1, {
+      identifier,
+      representation: sourceRepresentation,
+      preferences,
     });
   });
 
-  it('keeps the representation if the conversion is not required.', async(): Promise<void> => {
-    const id = { path: 'identifier' };
-
-    await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined();
-    expect(source.addResource).toHaveBeenLastCalledWith(id, representation, 'conditions');
-
-    await expect(store.setRepresentation(id, representation, 'conditions' as any)).resolves.toBeUndefined();
-    expect(inConverter.handleSafe).toHaveBeenCalledTimes(0);
-    expect(source.setRepresentation).toHaveBeenLastCalledWith(id, representation, 'conditions');
-
-    store = new RepresentationConvertingStore(source, {});
-    await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined();
-    expect(source.addResource).toHaveBeenLastCalledWith(id, representation, 'conditions');
+  it('calls the incoming converter when adding resources.', async(): Promise<void> => {
+    await expect(store.addResource(identifier, representation, 'conditions' as any)).resolves.toBeUndefined();
+    expect(inConverter.handleSafe).toHaveBeenCalledTimes(1);
+    expect(inConverter.handleSafe).toHaveBeenNthCalledWith(1, {
+      identifier,
+      representation,
+      preferences: { type: { 'text/turtle': 1 }},
+    });
+    expect(source.addResource).toHaveBeenLastCalledWith(identifier, convertedIn, 'conditions');
   });
 
-  it('converts the data if it is required.', async(): Promise<void> => {
-    metadata.contentType = 'text/plain';
-    const id = { path: 'identifier' };
-
-    await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined();
+  it('calls the incoming converter when setting representations.', async(): Promise<void> => {
+    await expect(store.setRepresentation(identifier, representation, 'conditions' as any)).resolves.toBeUndefined();
     expect(inConverter.handleSafe).toHaveBeenCalledTimes(1);
-    expect(source.addResource).toHaveBeenLastCalledWith(id, convertedIn, 'conditions');
-
-    await expect(store.setRepresentation(id, representation, 'conditions' as any)).resolves.toBeUndefined();
-    expect(inConverter.handleSafe).toHaveBeenCalledTimes(2);
-    expect(source.setRepresentation).toHaveBeenLastCalledWith(id, convertedIn, 'conditions');
+    expect(inConverter.handleSafe).toHaveBeenNthCalledWith(1, {
+      identifier,
+      representation,
+      preferences: { type: { 'text/turtle': 1 }},
+    });
+    expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, convertedIn, 'conditions');
   });
 
-  it('throws an error if no content-type is provided.', async(): Promise<void> => {
-    metadata.removeAll(CONTENT_TYPE);
-    const id = { path: 'identifier' };
-
-    await expect(store.addResource(id, representation, 'conditions' as any)).rejects.toThrow(InternalServerError);
+  it('does not perform any conversions when constructed with empty arguments.', async(): Promise<void> => {
+    const noArgStore = new RepresentationConvertingStore(source, {});
+    await expect(noArgStore.getRepresentation(identifier, preferences)).resolves.toEqual(sourceRepresentation);
+    await expect(noArgStore.addResource(identifier, representation)).resolves.toBeUndefined();
+    await expect(noArgStore.setRepresentation(identifier, representation)).resolves.toBeUndefined();
   });
 });
diff --git a/test/unit/storage/conversion/IfNeededConverter.test.ts b/test/unit/storage/conversion/IfNeededConverter.test.ts
new file mode 100644
index 000000000..90b52a616
--- /dev/null
+++ b/test/unit/storage/conversion/IfNeededConverter.test.ts
@@ -0,0 +1,103 @@
+import type { Representation } from '../../../../src/ldp/representation/Representation';
+import { IfNeededConverter } from '../../../../src/storage/conversion/IfNeededConverter';
+import type {
+  RepresentationConverter,
+} from '../../../../src/storage/conversion/RepresentationConverter';
+
+describe('An IfNeededConverter', (): void => {
+  const identifier = { path: 'identifier' };
+  const representation: Representation = {
+    metadata: { contentType: 'text/turtle' },
+  } as any;
+  const converted = {
+    metadata: { contentType: 'application/ld+json' },
+  };
+
+  const innerConverter: jest.Mocked<RepresentationConverter> = {
+    canHandle: jest.fn().mockResolvedValue(true),
+    handle: jest.fn().mockResolvedValue(converted),
+    handleSafe: jest.fn().mockResolvedValue(converted),
+  } as any;
+
+  const converter = new IfNeededConverter(innerConverter);
+
+  afterEach((): void => {
+    jest.clearAllMocks();
+  });
+
+  it('performs no conversion when there are no content type preferences.', async(): Promise<void> => {
+    const preferences = {};
+    const args = { identifier, representation, preferences };
+
+    await expect(converter.canHandle(args)).resolves.toBeUndefined();
+    await expect(converter.handle(args)).resolves.toBe(representation);
+    await expect(converter.handleSafe(args)).resolves.toBe(representation);
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0);
+  });
+
+  it('errors if no content type is specified on the representation.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 1 }};
+    const args = { identifier, representation: { metadata: {}} as any, preferences };
+
+    await expect(converter.handleSafe(args)).rejects
+      .toThrow('Content-Type is required for data conversion.');
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0);
+  });
+
+  it('performs no conversion when the content type matches the preferences.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 1 }};
+    const args = { identifier, representation, preferences };
+
+    await expect(converter.handleSafe(args)).resolves.toBe(representation);
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0);
+  });
+
+  it('performs a conversion when the content type matches the preferences.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 0 }};
+    const args = { identifier, representation, preferences };
+
+    await expect(converter.handleSafe(args)).resolves.toBe(converted);
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handle).toHaveBeenCalledTimes(0);
+    expect(innerConverter.handleSafe).toHaveBeenCalledTimes(1);
+    expect(innerConverter.handleSafe).toHaveBeenCalledWith(args);
+  });
+
+  it('does not support conversion when the inner converter does not support it.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 0 }};
+    const args = { identifier, representation, preferences };
+    const error = new Error('unsupported');
+    innerConverter.canHandle.mockRejectedValueOnce(error);
+
+    await expect(converter.canHandle(args)).rejects.toThrow(error);
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(1);
+    expect(innerConverter.canHandle).toHaveBeenCalledWith(args);
+  });
+
+  it('supports conversion when the inner converter supports it.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 0 }};
+    const args = { identifier, representation, preferences };
+
+    await expect(converter.canHandle(args)).resolves.toBeUndefined();
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(1);
+    expect(innerConverter.canHandle).toHaveBeenCalledWith(args);
+
+    await expect(converter.handle(args)).resolves.toBe(converted);
+
+    expect(innerConverter.canHandle).toHaveBeenCalledTimes(1);
+    expect(innerConverter.handle).toHaveBeenCalledTimes(1);
+    expect(innerConverter.handle).toHaveBeenCalledWith(args);
+  });
+});
diff --git a/test/unit/storage/conversion/PassthroughConverter.test.ts b/test/unit/storage/conversion/PassthroughConverter.test.ts
new file mode 100644
index 000000000..c64411dce
--- /dev/null
+++ b/test/unit/storage/conversion/PassthroughConverter.test.ts
@@ -0,0 +1,20 @@
+import { PassthroughConverter } from '../../../../src/storage/conversion/PassthroughConverter';
+
+describe('A PassthroughConverter', (): void => {
+  const representation = {};
+  const args = { representation } as any;
+
+  const converter = new PassthroughConverter();
+
+  it('supports any conversion.', async(): Promise<void> => {
+    await expect(converter.canHandle(args)).resolves.toBeUndefined();
+  });
+
+  it('returns the original representation on handle.', async(): Promise<void> => {
+    await expect(converter.handle(args)).resolves.toBe(representation);
+  });
+
+  it('returns the original representation on handleSafe.', async(): Promise<void> => {
+    await expect(converter.handleSafe(args)).resolves.toBe(representation);
+  });
+});