Repository: solid/community-server (ts)

Issue:
Support HEAD requests
`HEAD` requests are not supported yet, so we should implement a handler.
Possibly similar or derived from `SimpleGetOperationHandler`.

Pull request description:
Implement HEAD request support
This closes #85.

~~I have not yet managed to fully cover the code in the `process.nextTick` in `HeadOperationHandler#30`.
An explicit expect to see that it's an empty Readable didn't work either, because jest expects a Readable for some reason, but gets an Object with the same content.~~

Patch:
diff --git a/.gitignore b/.gitignore
index 77853db5c..9dbdcde65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,8 @@ coverage
 *.js.map
 *.d.ts
 
+.eslintcache
+
 !.eslintrc.js
 !test/eslintrc.js
 !jest.config.js
diff --git a/config/presets/ldp/operation-handler.json b/config/presets/ldp/operation-handler.json
index 6ca76e7a1..c81dc03f1 100644
--- a/config/presets/ldp/operation-handler.json
+++ b/config/presets/ldp/operation-handler.json
@@ -17,6 +17,12 @@
             "@id": "urn:solid-server:default:ResourceStore_Patching"
           }
         },
+        {
+          "@type": "HeadOperationHandler",
+          "HeadOperationHandler:_store": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching"
+          }
+        },
         {
           "@type": "PatchOperationHandler",
           "PatchOperationHandler:_store": {
diff --git a/index.ts b/index.ts
index 8066b7684..9413f7026 100644
--- a/index.ts
+++ b/index.ts
@@ -42,6 +42,7 @@ export * from './src/logging/WinstonLoggerFactory';
 // LDP/Operations
 export * from './src/ldp/operations/DeleteOperationHandler';
 export * from './src/ldp/operations/GetOperationHandler';
+export * from './src/ldp/operations/HeadOperationHandler';
 export * from './src/ldp/operations/Operation';
 export * from './src/ldp/operations/OperationHandler';
 export * from './src/ldp/operations/PatchOperationHandler';
@@ -74,10 +75,8 @@ export * from './src/server/HttpResponse';
 // Storage/Conversion
 export * from './src/storage/conversion/ChainedConverter';
 export * from './src/storage/conversion/QuadToRdfConverter';
-export * from './src/storage/conversion/QuadToTurtleConverter';
 export * from './src/storage/conversion/RdfToQuadConverter';
 export * from './src/storage/conversion/RepresentationConverter';
-export * from './src/storage/conversion/TurtleToQuadConverter';
 export * from './src/storage/conversion/TypedRepresentationConverter';
 
 // Storage/Patch
diff --git a/package.json b/package.json
index 1bc8a9c5d..cd992fd84 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,7 @@
     "build:ts": "tsc",
     "build:components": "componentsjs-generator -s src",
     "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls",
-    "lint": "eslint . --ext .ts",
+    "lint": "eslint . --ext .ts --cache",
     "prepare": "npm run build",
     "start": "node ./bin/server.js -p 3000",
     "test": "jest",
diff --git a/src/ldp/http/BasicRequestParser.ts b/src/ldp/http/BasicRequestParser.ts
index 6a2b4a3e3..3b504ee60 100644
--- a/src/ldp/http/BasicRequestParser.ts
+++ b/src/ldp/http/BasicRequestParser.ts
@@ -28,20 +28,18 @@ export class BasicRequestParser extends RequestParser {
     Object.assign(this, args);
   }
 
-  public async canHandle(input: HttpRequest): Promise<void> {
-    if (!input.url) {
-      throw new Error('Missing URL.');
-    }
-    if (!input.method) {
-      throw new Error('Missing method.');
-    }
+  public async canHandle(): Promise<void> {
+    // Can handle all requests
   }
 
   public async handle(input: HttpRequest): Promise<Operation> {
+    if (!input.method) {
+      throw new Error('Missing method.');
+    }
     const target = await this.targetExtractor.handleSafe(input);
     const preferences = await this.preferenceParser.handleSafe(input);
     const body = await this.bodyParser.handleSafe(input);
 
-    return { method: input.method!, target, preferences, body };
+    return { method: input.method, target, preferences, body };
   }
 }
diff --git a/src/ldp/http/BasicResponseWriter.ts b/src/ldp/http/BasicResponseWriter.ts
index 00919fdef..6e08b2e4e 100644
--- a/src/ldp/http/BasicResponseWriter.ts
+++ b/src/ldp/http/BasicResponseWriter.ts
@@ -10,10 +10,8 @@ import { ResponseWriter } from './ResponseWriter';
  */
 export class BasicResponseWriter extends ResponseWriter {
   public async canHandle(input: { response: HttpResponse; result: ResponseDescription | Error }): Promise<void> {
-    if (!(input.result instanceof Error)) {
-      if (input.result.body && !input.result.body.binary) {
-        throw new UnsupportedHttpError('Only binary results are supported.');
-      }
+    if (!(input.result instanceof Error) && input.result.body && !input.result.body.binary) {
+      throw new UnsupportedHttpError('Only binary results and errors are supported.');
     }
   }
 
diff --git a/src/ldp/http/BasicTargetExtractor.ts b/src/ldp/http/BasicTargetExtractor.ts
index 5d94727c4..7138167ba 100644
--- a/src/ldp/http/BasicTargetExtractor.ts
+++ b/src/ldp/http/BasicTargetExtractor.ts
@@ -10,16 +10,17 @@ import { TargetExtractor } from './TargetExtractor';
  * TODO: input requires more extensive cleaning/parsing based on headers (see #22).
  */
 export class BasicTargetExtractor extends TargetExtractor {
-  public async canHandle(input: HttpRequest): Promise<void> {
+  public async canHandle(): Promise<void> {
+    // Can handle all URLs
+  }
+
+  public async handle(input: HttpRequest): Promise<ResourceIdentifier> {
     if (!input.url) {
       throw new Error('Missing URL.');
     }
     if (!input.headers.host) {
       throw new Error('Missing host.');
     }
-  }
-
-  public async handle(input: HttpRequest): Promise<ResourceIdentifier> {
     const isHttps = input.connection && (input.connection as TLSSocket).encrypted;
     const url = format({
       protocol: `http${isHttps ? 's' : ''}`,
diff --git a/src/ldp/http/RawBodyParser.ts b/src/ldp/http/RawBodyParser.ts
index 8e8402a45..9f652a86e 100644
--- a/src/ldp/http/RawBodyParser.ts
+++ b/src/ldp/http/RawBodyParser.ts
@@ -27,7 +27,7 @@ export class RawBodyParser extends BodyParser {
     // While RFC7231 allows treating a body without content type as an octet stream,
     // such an omission likely signals a mistake, so force clients to make this explicit.
     if (!input.headers['content-type']) {
-      throw new Error('An HTTP request body was passed without Content-Type header');
+      throw new UnsupportedHttpError('An HTTP request body was passed without Content-Type header');
     }
 
     return {
diff --git a/src/ldp/http/SparqlUpdateBodyParser.ts b/src/ldp/http/SparqlUpdateBodyParser.ts
index 873a0a533..6ead8058b 100644
--- a/src/ldp/http/SparqlUpdateBodyParser.ts
+++ b/src/ldp/http/SparqlUpdateBodyParser.ts
@@ -1,6 +1,7 @@
 import { PassThrough } from 'stream';
 import { translate } from 'sparqlalgebrajs';
 import type { HttpRequest } from '../../server/HttpRequest';
+import { APPLICATION_SPARQL_UPDATE } from '../../util/ContentTypes';
 import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
 import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError';
 import { CONTENT_TYPE } from '../../util/UriConstants';
@@ -16,9 +17,7 @@ import type { SparqlUpdatePatch } from './SparqlUpdatePatch';
  */
 export class SparqlUpdateBodyParser extends BodyParser {
   public async canHandle(input: HttpRequest): Promise<void> {
-    const contentType = input.headers['content-type'];
-
-    if (!contentType || contentType !== 'application/sparql-update') {
+    if (input.headers['content-type'] !== APPLICATION_SPARQL_UPDATE) {
       throw new UnsupportedMediaTypeHttpError('This parser only supports SPARQL UPDATE data.');
     }
   }
@@ -35,7 +34,7 @@ export class SparqlUpdateBodyParser extends BodyParser {
       const sparql = await readableToString(toAlgebraStream);
       const algebra = translate(sparql, { quads: true });
 
-      const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'application/sparql-update' });
+      const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: APPLICATION_SPARQL_UPDATE });
 
       // Prevent body from being requested again
       return {
diff --git a/src/ldp/operations/HeadOperationHandler.ts b/src/ldp/operations/HeadOperationHandler.ts
new file mode 100644
index 000000000..b17e497ff
--- /dev/null
+++ b/src/ldp/operations/HeadOperationHandler.ts
@@ -0,0 +1,37 @@
+import { Readable } from 'stream';
+import type { ResourceStore } from '../../storage/ResourceStore';
+import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
+import type { Operation } from './Operation';
+import { OperationHandler } from './OperationHandler';
+import type { ResponseDescription } from './ResponseDescription';
+
+/**
+ * Handles HEAD {@link Operation}s.
+ * Calls the getRepresentation function from a {@link ResourceStore}.
+ */
+export class HeadOperationHandler extends OperationHandler {
+  private readonly store: ResourceStore;
+
+  public constructor(store: ResourceStore) {
+    super();
+    this.store = store;
+  }
+
+  public async canHandle(input: Operation): Promise<void> {
+    if (input.method !== 'HEAD') {
+      throw new UnsupportedHttpError('This handler only supports HEAD operations.');
+    }
+  }
+
+  public async handle(input: Operation): Promise<ResponseDescription> {
+    const body = await this.store.getRepresentation(input.target, input.preferences);
+
+    // Close the Readable as we will not return it.
+    body.data.destroy();
+    body.data = new Readable();
+    body.data._read = function(): void {
+      body.data.push(null);
+    };
+    return { identifier: input.target, body };
+  }
+}
diff --git a/src/ldp/operations/PostOperationHandler.ts b/src/ldp/operations/PostOperationHandler.ts
index f70ac892b..3926654e7 100644
--- a/src/ldp/operations/PostOperationHandler.ts
+++ b/src/ldp/operations/PostOperationHandler.ts
@@ -20,13 +20,13 @@ export class PostOperationHandler extends OperationHandler {
     if (input.method !== 'POST') {
       throw new UnsupportedHttpError('This handler only supports POST operations.');
     }
-    if (!input.body) {
-      throw new UnsupportedHttpError('POST operations require a body.');
-    }
   }
 
   public async handle(input: Operation): Promise<ResponseDescription> {
-    const identifier = await this.store.addResource(input.target, input.body!);
+    if (!input.body) {
+      throw new UnsupportedHttpError('POST operations require a body.');
+    }
+    const identifier = await this.store.addResource(input.target, input.body);
     return { identifier };
   }
 }
diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts
index 5e6c9babc..15ba3092a 100644
--- a/src/storage/conversion/ChainedConverter.ts
+++ b/src/storage/conversion/ChainedConverter.ts
@@ -1,8 +1,6 @@
 import type { Representation } from '../../ldp/representation/Representation';
-import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
-import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
-import { CONTENT_TYPE } from '../../util/UriConstants';
 import { matchingMediaType } from '../../util/Util';
+import { checkRequest } from './ConversionUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 import { TypedRepresentationConverter } from './TypedRepresentationConverter';
 
@@ -44,18 +42,14 @@ export class ChainedConverter extends TypedRepresentationConverter {
 
   public async canHandle(input: RepresentationConverterArgs): Promise<void> {
     // We assume a chain can be constructed, otherwise there would be a configuration issue
-    // Check if the first converter can handle the input
-    const firstChain = await this.getMatchingType(this.converters[0], this.converters[1]);
-    const preferences: RepresentationPreferences = { type: [{ value: firstChain, weight: 1 }]};
-    await this.first.canHandle({ ...input, preferences });
+    // So we only check if the input can be parsed and the preferred type can be written
+    const inTypes = this.filterTypes(await this.first.getInputTypes());
+    const outTypes = this.filterTypes(await this.last.getOutputTypes());
+    checkRequest(input, inTypes, outTypes);
+  }
 
-    // Check if the last converter can produce the output
-    const idx = this.converters.length - 1;
-    const lastChain = await this.getMatchingType(this.converters[idx - 1], this.converters[idx]);
-    const oldMeta = input.representation.metadata;
-    const metadata = new RepresentationMetadata(oldMeta, { [CONTENT_TYPE]: lastChain });
-    const representation: Representation = { ...input.representation, metadata };
-    await this.last.canHandle({ ...input, representation });
+  private filterTypes(typeVals: { [contentType: string]: number }): string[] {
+    return Object.keys(typeVals).filter((name): boolean => typeVals[name] > 0);
   }
 
   public async handle(input: RepresentationConverterArgs): Promise<Representation> {
diff --git a/src/storage/conversion/QuadToTurtleConverter.ts b/src/storage/conversion/QuadToTurtleConverter.ts
deleted file mode 100644
index e17fb8bbf..000000000
--- a/src/storage/conversion/QuadToTurtleConverter.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { StreamWriter } from 'n3';
-import type { Representation } from '../../ldp/representation/Representation';
-import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
-import { INTERNAL_QUADS, TEXT_TURTLE } from '../../util/ContentTypes';
-import { CONTENT_TYPE } from '../../util/UriConstants';
-import { checkRequest } from './ConversionUtil';
-import type { RepresentationConverterArgs } from './RepresentationConverter';
-import { RepresentationConverter } from './RepresentationConverter';
-
-/**
- * Converts `internal/quads` to `text/turtle`.
- */
-export class QuadToTurtleConverter extends RepresentationConverter {
-  public async canHandle(input: RepresentationConverterArgs): Promise<void> {
-    checkRequest(input, [ INTERNAL_QUADS ], [ TEXT_TURTLE ]);
-  }
-
-  public async handle(input: RepresentationConverterArgs): Promise<Representation> {
-    return this.quadsToTurtle(input.representation);
-  }
-
-  private quadsToTurtle(quads: Representation): Representation {
-    const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: TEXT_TURTLE });
-    return {
-      binary: true,
-      data: quads.data.pipe(new StreamWriter({ format: TEXT_TURTLE })),
-      metadata,
-    };
-  }
-}
diff --git a/src/storage/conversion/TurtleToQuadConverter.ts b/src/storage/conversion/TurtleToQuadConverter.ts
deleted file mode 100644
index a78488ea0..000000000
--- a/src/storage/conversion/TurtleToQuadConverter.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { PassThrough } from 'stream';
-import { StreamParser } from 'n3';
-import type { Representation } from '../../ldp/representation/Representation';
-import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
-import { TEXT_TURTLE, INTERNAL_QUADS } from '../../util/ContentTypes';
-import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
-import { CONTENT_TYPE } from '../../util/UriConstants';
-import { checkRequest } from './ConversionUtil';
-import type { RepresentationConverterArgs } from './RepresentationConverter';
-import { RepresentationConverter } from './RepresentationConverter';
-
-/**
- * Converts `text/turtle` to `internal/quads`.
- */
-export class TurtleToQuadConverter extends RepresentationConverter {
-  public async canHandle(input: RepresentationConverterArgs): Promise<void> {
-    checkRequest(input, [ TEXT_TURTLE ], [ INTERNAL_QUADS ]);
-  }
-
-  public async handle(input: RepresentationConverterArgs): Promise<Representation> {
-    return this.turtleToQuads(input.representation, input.identifier.path);
-  }
-
-  private turtleToQuads(turtle: Representation, baseIRI: string): Representation {
-    const metadata = new RepresentationMetadata(turtle.metadata, { [CONTENT_TYPE]: INTERNAL_QUADS });
-
-    // Catch parsing errors and emit correct error
-    // Node 10 requires both writableObjectMode and readableObjectMode
-    const errorStream = new PassThrough({ writableObjectMode: true, readableObjectMode: true });
-    const data = turtle.data.pipe(new StreamParser({ format: TEXT_TURTLE, baseIRI }));
-    data.pipe(errorStream);
-    data.on('error', (error): boolean => errorStream.emit('error', new UnsupportedHttpError(error.message)));
-
-    return {
-      binary: false,
-      data: errorStream,
-      metadata,
-    };
-  }
-}
diff --git a/src/util/CompositeAsyncHandler.ts b/src/util/CompositeAsyncHandler.ts
index b1fbe887c..ea21a6caf 100644
--- a/src/util/CompositeAsyncHandler.ts
+++ b/src/util/CompositeAsyncHandler.ts
@@ -1,4 +1,6 @@
 import type { AsyncHandler } from './AsyncHandler';
+import { HttpError } from './errors/HttpError';
+import { InternalServerError } from './errors/InternalServerError';
 import { UnsupportedHttpError } from './errors/UnsupportedHttpError';
 
 /**
@@ -70,7 +72,7 @@ export class CompositeAsyncHandler<TIn, TOut> implements AsyncHandler<TIn, TOut>
    * @returns A promise resolving to a handler that supports the data or otherwise rejecting.
    */
   private async findHandler(input: TIn): Promise<AsyncHandler<TIn, TOut>> {
-    const errors: Error[] = [];
+    const errors: HttpError[] = [];
 
     for (const handler of this.handlers) {
       try {
@@ -78,16 +80,28 @@ export class CompositeAsyncHandler<TIn, TOut> implements AsyncHandler<TIn, TOut>
 
         return handler;
       } catch (error: unknown) {
-        if (error instanceof Error) {
+        if (error instanceof HttpError) {
           errors.push(error);
+        } else if (error instanceof Error) {
+          errors.push(new InternalServerError(error.message));
         } else {
-          errors.push(new Error('Unknown error.'));
+          errors.push(new InternalServerError('Unknown error.'));
         }
       }
     }
 
     const joined = errors.map((error: Error): string => error.message).join(', ');
+    const message = `No handler supports the given input: [${joined}].`;
 
-    throw new UnsupportedHttpError(`No handler supports the given input: [${joined}].`);
+    // Check if all errors have the same status code
+    if (errors.every((error): boolean => error.statusCode === errors[0].statusCode)) {
+      throw new HttpError(errors[0].statusCode, errors[0].name, message);
+    }
+
+    // Find the error range (4xx or 5xx)
+    if (errors.some((error): boolean => error.statusCode >= 500)) {
+      throw new InternalServerError(message);
+    }
+    throw new UnsupportedHttpError(message);
   }
 }
diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts
index 09d8750d2..3c22a6e75 100644
--- a/src/util/ContentTypes.ts
+++ b/src/util/ContentTypes.ts
@@ -1,6 +1,7 @@
 // Well-known content types
 export const TEXT_TURTLE = 'text/turtle';
 export const APPLICATION_OCTET_STREAM = 'application/octet-stream';
+export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update';
 
 // Internal (non-exposed) content types
 export const INTERNAL_QUADS = 'internal/quads';
diff --git a/src/util/errors/HttpError.ts b/src/util/errors/HttpError.ts
index f940ac30e..a443cca24 100644
--- a/src/util/errors/HttpError.ts
+++ b/src/util/errors/HttpError.ts
@@ -1,8 +1,8 @@
 /**
- * An abstract class for all errors that could be thrown by Solid.
+ * A class for all errors that could be thrown by Solid.
  * All errors inheriting from this should fix the status code thereby hiding the HTTP internals from other components.
  */
-export abstract class HttpError extends Error {
+export class HttpError extends Error {
   public statusCode: number;
 
   /**
@@ -11,7 +11,7 @@ export abstract class HttpError extends Error {
    * @param name - Error name. Useful for logging and stack tracing.
    * @param message - Message to be thrown.
    */
-  protected constructor(statusCode: number, name: string, message?: string) {
+  public constructor(statusCode: number, name: string, message?: string) {
     super(message);
     this.statusCode = statusCode;
     this.name = name;
diff --git a/src/util/errors/InternalServerError.ts b/src/util/errors/InternalServerError.ts
new file mode 100644
index 000000000..25c7c0843
--- /dev/null
+++ b/src/util/errors/InternalServerError.ts
@@ -0,0 +1,9 @@
+import { HttpError } from './HttpError';
+/**
+ * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
+ */
+export class InternalServerError extends HttpError {
+  public constructor(message?: string) {
+    super(500, 'InternalServerError', message);
+  }
+}


Test patch:
diff --git a/test/configs/Util.ts b/test/configs/Util.ts
index e98e7178a..d27f52e67 100644
--- a/test/configs/Util.ts
+++ b/test/configs/Util.ts
@@ -14,6 +14,7 @@ import {
   DeleteOperationHandler,
   FileResourceStore,
   GetOperationHandler,
+  HeadOperationHandler,
   InMemoryResourceStore,
   InteractionController,
   MetadataController,
@@ -94,6 +95,7 @@ export const getPatchingStore = (store: ResourceStore): PatchingStore => {
 export const getOperationHandler = (store: ResourceStore): CompositeAsyncHandler<Operation, ResponseDescription> => {
   const handlers = [
     new GetOperationHandler(store),
+    new HeadOperationHandler(store),
     new PostOperationHandler(store),
     new PutOperationHandler(store),
     new PatchOperationHandler(store),
diff --git a/test/unit/ldp/http/BasicRequestParser.test.ts b/test/unit/ldp/http/BasicRequestParser.test.ts
index 5c298f1f6..f63796199 100644
--- a/test/unit/ldp/http/BasicRequestParser.test.ts
+++ b/test/unit/ldp/http/BasicRequestParser.test.ts
@@ -17,16 +17,12 @@ describe('A BasicRequestParser', (): void => {
     requestParser = new BasicRequestParser({ targetExtractor, bodyParser, preferenceParser });
   });
 
-  it('can handle input with both a URL and a method.', async(): Promise<void> => {
-    await expect(requestParser.canHandle({ url: 'url', method: 'GET' } as any)).resolves.toBeUndefined();
+  it('can handle any input.', async(): Promise<void> => {
+    await expect(requestParser.canHandle()).resolves.toBeUndefined();
   });
 
-  it('rejects input with no URL.', async(): Promise<void> => {
-    await expect(requestParser.canHandle({ method: 'GET' } as any)).rejects.toThrow('Missing URL.');
-  });
-
-  it('rejects input with no method.', async(): Promise<void> => {
-    await expect(requestParser.canHandle({ url: 'url' } as any)).rejects.toThrow('Missing method.');
+  it('errors if there is no input.', async(): Promise<void> => {
+    await expect(requestParser.handle({ url: 'url' } as any)).rejects.toThrow('Missing method.');
   });
 
   it('returns the output of all input parsers after calling handle.', async(): Promise<void> => {
diff --git a/test/unit/ldp/http/BasicTargetExtractor.test.ts b/test/unit/ldp/http/BasicTargetExtractor.test.ts
index 5a73e9cfe..8d2f3d5fc 100644
--- a/test/unit/ldp/http/BasicTargetExtractor.test.ts
+++ b/test/unit/ldp/http/BasicTargetExtractor.test.ts
@@ -3,16 +3,16 @@ import { BasicTargetExtractor } from '../../../../src/ldp/http/BasicTargetExtrac
 describe('A BasicTargetExtractor', (): void => {
   const extractor = new BasicTargetExtractor();
 
-  it('can handle input with an URL and host.', async(): Promise<void> => {
-    await expect(extractor.canHandle({ url: 'url', headers: { host: 'test.com' }} as any)).resolves.toBeUndefined();
+  it('can handle any input.', async(): Promise<void> => {
+    await expect(extractor.canHandle()).resolves.toBeUndefined();
   });
 
-  it('rejects input without URL.', async(): Promise<void> => {
-    await expect(extractor.canHandle({ headers: { host: 'test.com' }} as any)).rejects.toThrow('Missing URL.');
+  it('errors if there is no URL.', async(): Promise<void> => {
+    await expect(extractor.handle({ headers: { host: 'test.com' }} as any)).rejects.toThrow('Missing URL.');
   });
 
-  it('rejects input without host.', async(): Promise<void> => {
-    await expect(extractor.canHandle({ url: 'url', headers: {}} as any)).rejects.toThrow('Missing host.');
+  it('errors if there is no host.', async(): Promise<void> => {
+    await expect(extractor.handle({ url: 'url', headers: {}} as any)).rejects.toThrow('Missing host.');
   });
 
   it('returns the input URL.', async(): Promise<void> => {
diff --git a/test/unit/ldp/operations/HeadOperationHandler.test.ts b/test/unit/ldp/operations/HeadOperationHandler.test.ts
new file mode 100644
index 000000000..d0914ed25
--- /dev/null
+++ b/test/unit/ldp/operations/HeadOperationHandler.test.ts
@@ -0,0 +1,28 @@
+import arrayifyStream from 'arrayify-stream';
+import streamifyArray from 'streamify-array';
+import { HeadOperationHandler } from '../../../../src/ldp/operations/HeadOperationHandler';
+import type { Operation } from '../../../../src/ldp/operations/Operation';
+import type { Representation } from '../../../../src/ldp/representation/Representation';
+import type { ResourceStore } from '../../../../src/storage/ResourceStore';
+import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
+
+describe('A HeadOperationHandler', (): void => {
+  const store = {
+    getRepresentation: async(): Promise<Representation> => ({ binary: false, data: streamifyArray([ 1, 2, 3 ]) } as
+      Representation),
+  } as unknown as ResourceStore;
+  const handler = new HeadOperationHandler(store);
+
+  it('only supports HEAD operations.', async(): Promise<void> => {
+    await expect(handler.canHandle({ method: 'HEAD' } as Operation)).resolves.toBeUndefined();
+    await expect(handler.canHandle({ method: 'GET' } as Operation)).rejects.toThrow(UnsupportedHttpError);
+    await expect(handler.canHandle({ method: 'POST' } as Operation)).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('returns the representation from the store with the input identifier and empty data.', async(): Promise<void> => {
+    const result = await handler.handle({ target: { path: 'url' }} as Operation);
+    expect(result.identifier.path).toBe('url');
+    expect(result.body?.binary).toBe(false);
+    await expect(arrayifyStream(result.body!.data)).resolves.toEqual([]);
+  });
+});
diff --git a/test/unit/ldp/operations/PostOperationHandler.test.ts b/test/unit/ldp/operations/PostOperationHandler.test.ts
index f1e70a2fc..3eff9eba7 100644
--- a/test/unit/ldp/operations/PostOperationHandler.test.ts
+++ b/test/unit/ldp/operations/PostOperationHandler.test.ts
@@ -10,12 +10,15 @@ describe('A PostOperationHandler', (): void => {
   } as unknown as ResourceStore;
   const handler = new PostOperationHandler(store);
 
-  it('only supports POST operations with a body.', async(): Promise<void> => {
+  it('only supports POST operations.', async(): Promise<void> => {
     await expect(handler.canHandle({ method: 'POST', body: { }} as Operation))
       .resolves.toBeUndefined();
     await expect(handler.canHandle({ method: 'GET', body: { }} as Operation))
       .rejects.toThrow(UnsupportedHttpError);
-    await expect(handler.canHandle({ method: 'POST' } as Operation)).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('errors if there is no body.', async(): Promise<void> => {
+    await expect(handler.handle({ method: 'POST' } as Operation)).rejects.toThrow(UnsupportedHttpError);
   });
 
   it('adds the given representation to the store and returns the new identifier.', async(): Promise<void> => {
diff --git a/test/unit/storage/conversion/QuadToTurtleConverter.test.ts b/test/unit/storage/conversion/QuadToTurtleConverter.test.ts
deleted file mode 100644
index f84868de7..000000000
--- a/test/unit/storage/conversion/QuadToTurtleConverter.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { namedNode, triple } from '@rdfjs/data-model';
-import arrayifyStream from 'arrayify-stream';
-import streamifyArray from 'streamify-array';
-import type { Representation } from '../../../../src/ldp/representation/Representation';
-import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
-import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
-import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
-import { QuadToTurtleConverter } from '../../../../src/storage/conversion/QuadToTurtleConverter';
-import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
-import { CONTENT_TYPE } from '../../../../src/util/UriConstants';
-
-describe('A QuadToTurtleConverter', (): void => {
-  const converter = new QuadToTurtleConverter();
-  const identifier: ResourceIdentifier = { path: 'path' };
-  const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: INTERNAL_QUADS });
-
-  it('can handle quad to turtle conversions.', async(): Promise<void> => {
-    const representation = { metadata } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: 'text/turtle', weight: 1 }]};
-    await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined();
-  });
-
-  it('converts quads to turtle.', async(): Promise<void> => {
-    const representation = {
-      data: streamifyArray([ triple(
-        namedNode('http://test.com/s'),
-        namedNode('http://test.com/p'),
-        namedNode('http://test.com/o'),
-      ) ]),
-      metadata,
-    } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: 'text/turtle', weight: 1 }]};
-    const result = await converter.handle({ identifier, representation, preferences });
-    expect(result).toMatchObject({
-      binary: true,
-      metadata: expect.any(RepresentationMetadata),
-    });
-    expect(result.metadata.contentType).toEqual('text/turtle');
-    await expect(arrayifyStream(result.data)).resolves.toContain(
-      '<http://test.com/s> <http://test.com/p> <http://test.com/o>',
-    );
-  });
-});
diff --git a/test/unit/storage/conversion/TurtleToQuadConverter.test.ts b/test/unit/storage/conversion/TurtleToQuadConverter.test.ts
deleted file mode 100644
index 3192aa9b7..000000000
--- a/test/unit/storage/conversion/TurtleToQuadConverter.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Readable } from 'stream';
-import { namedNode, triple } from '@rdfjs/data-model';
-import arrayifyStream from 'arrayify-stream';
-import streamifyArray from 'streamify-array';
-import type { Representation } from '../../../../src/ldp/representation/Representation';
-import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
-import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
-import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
-import { TurtleToQuadConverter } from '../../../../src/storage/conversion/TurtleToQuadConverter';
-import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
-import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
-import { CONTENT_TYPE } from '../../../../src/util/UriConstants';
-
-describe('A TurtleToQuadConverter', (): void => {
-  const converter = new TurtleToQuadConverter();
-  const identifier: ResourceIdentifier = { path: 'path' };
-  const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' });
-
-  it('can handle turtle to quad conversions.', async(): Promise<void> => {
-    const representation = { metadata } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
-    await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined();
-  });
-
-  it('converts turtle to quads.', async(): Promise<void> => {
-    const representation = {
-      data: streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]),
-      metadata,
-    } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
-    const result = await converter.handle({ identifier, representation, preferences });
-    expect(result).toEqual({
-      binary: false,
-      data: expect.any(Readable),
-      metadata: expect.any(RepresentationMetadata),
-    });
-    expect(result.metadata.contentType).toEqual(INTERNAL_QUADS);
-    await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ triple(
-      namedNode('http://test.com/s'),
-      namedNode('http://test.com/p'),
-      namedNode('http://test.com/o'),
-    ) ]);
-  });
-
-  it('throws an UnsupportedHttpError on invalid triple data.', async(): Promise<void> => {
-    const representation = {
-      data: streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.co' ]),
-      metadata,
-    } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
-    const result = await converter.handle({ identifier, representation, preferences });
-    expect(result).toEqual({
-      binary: false,
-      data: expect.any(Readable),
-      metadata: expect.any(RepresentationMetadata),
-    });
-    expect(result.metadata.contentType).toEqual(INTERNAL_QUADS);
-    await expect(arrayifyStream(result.data)).rejects.toThrow(UnsupportedHttpError);
-  });
-});
diff --git a/test/unit/util/CompositeAsyncHandler.test.ts b/test/unit/util/CompositeAsyncHandler.test.ts
index 6dcaf73cf..268155c44 100644
--- a/test/unit/util/CompositeAsyncHandler.test.ts
+++ b/test/unit/util/CompositeAsyncHandler.test.ts
@@ -1,5 +1,7 @@
 import type { AsyncHandler } from '../../../src/util/AsyncHandler';
 import { CompositeAsyncHandler } from '../../../src/util/CompositeAsyncHandler';
+import { HttpError } from '../../../src/util/errors/HttpError';
+import { UnsupportedHttpError } from '../../../src/util/errors/UnsupportedHttpError';
 import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
 
 describe('A CompositeAsyncHandler', (): void => {
@@ -81,5 +83,44 @@ describe('A CompositeAsyncHandler', (): void => {
 
       await expect(handler.handleSafe(null)).rejects.toThrow('[Not supported., Not supported.]');
     });
+
+    it('throws an error with matching status code if all handlers threw the same.', async(): Promise<void> => {
+      handlerTrue.canHandle = async(): Promise<void> => {
+        throw new HttpError(401, 'UnauthorizedHttpError');
+      };
+      const handler = new CompositeAsyncHandler([ handlerTrue, handlerTrue ]);
+
+      await expect(handler.canHandle(null)).rejects.toMatchObject({
+        statusCode: 401,
+        name: 'UnauthorizedHttpError',
+      });
+    });
+
+    it('throws an internal server error if one of the handlers threw one.', async(): Promise<void> => {
+      handlerTrue.canHandle = async(): Promise<void> => {
+        throw new HttpError(401, 'UnauthorizedHttpError');
+      };
+      handlerFalse.canHandle = async(): Promise<void> => {
+        throw new Error('Server is crashing!');
+      };
+      const handler = new CompositeAsyncHandler([ handlerTrue, handlerFalse ]);
+
+      await expect(handler.canHandle(null)).rejects.toMatchObject({
+        statusCode: 500,
+        name: 'InternalServerError',
+      });
+    });
+
+    it('throws an UnsupportedHttpError if handlers throw different errors.', async(): Promise<void> => {
+      handlerTrue.canHandle = async(): Promise<void> => {
+        throw new HttpError(401, 'UnauthorizedHttpError');
+      };
+      handlerFalse.canHandle = async(): Promise<void> => {
+        throw new HttpError(415, 'UnsupportedMediaTypeHttpError');
+      };
+      const handler = new CompositeAsyncHandler([ handlerTrue, handlerFalse ]);
+
+      await expect(handler.canHandle(null)).rejects.toThrow(UnsupportedHttpError);
+    });
   });
 });