Repository: solid/community-server (ts)

Issue:
ChainedConverter conversion not deterministic
Given the code at https://github.com/solid/community-server/pull/833, follow these steps with a freshly started server:

```shell
curl -I -s -H "Accept: text/html,*/*;q=0.1" http://localhost:3000/ | grep -i content-type
curl -I -s -H "Accept: text/html,*/*;q=0.1" http://localhost:3000/ | grep -i content-type
curl -I -s -H "Accept: text/turtle" http://localhost:3000/         | grep -i content-type
curl -I -s -H "Accept: text/html,*/*;q=0.1" http://localhost:3000/ | grep -i content-type
curl -I -s -H "Accept: text/html,*/*;q=0.1" http://localhost:3000/ | grep -i content-type
curl -I -s -H "Accept: text/html" http://localhost:3000/           | grep -i content-type
curl -I -s -H "Accept: text/html,*/*;q=0.1" http://localhost:3000/ | grep -i content-type
curl -I -s -H "Accept: text/html,*/*;q=0.1" http://localhost:3000/ | grep -i content-type
```

Even though requests 1/2/4/5/7/8 are identical, 4 and 5 return Turtle whereas the others return HTML.
It seems that request 3 influences the `LruPathCache` in such a way that the optimal conversion is not chosen.

Pull request:
Remove cache from ChainedConverter
Closes #832.

Also included are the removal of an unnecessary dependency and updating the `TextErrorHandler` so its behaviour and name are more correct.

Patch:
diff --git a/config/ldp/handler/components/error-handler.json b/config/ldp/handler/components/error-handler.json
index 027c9f412..b8b12b918 100644
--- a/config/ldp/handler/components/error-handler.json
+++ b/config/ldp/handler/components/error-handler.json
@@ -2,20 +2,16 @@
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "@graph": [
     {
-      "comment": "Changes an error into a valid representation to send as a response.",
+      "comment": "Wraps around the main error handler as a fallback in case something goes wrong.",
       "@id": "urn:solid-server:default:ErrorHandler",
-      "@type": "WaterfallHandler",
-      "handlers": [
-        {
-          "@type": "ConvertingErrorHandler",
-          "converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
-          "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
-        },
-        {
-          "@type": "TextErrorHandler",
-          "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
-        }
-      ]
+      "@type": "SafeErrorHandler",
+      "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" },
+      "errorHandler": {
+        "comment": "Changes an error into a valid representation to send as a response.",
+        "@type": "ConvertingErrorHandler",
+        "converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
+        "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
+      }
     }
   ]
 }
diff --git a/package-lock.json b/package-lock.json
index aa1ad437b..6e14e0ae2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -103,7 +103,6 @@
         "nodemon": "^2.0.7",
         "rimraf": "^3.0.2",
         "set-cookie-parser": "^2.4.8",
-        "stream-to-string": "^1.2.0",
         "supertest": "^6.1.3",
         "ts-jest": "^27.0.3",
         "typedoc": "^0.21.2",
diff --git a/package.json b/package.json
index cc069bde1..2f91dfec2 100644
--- a/package.json
+++ b/package.json
@@ -166,7 +166,6 @@
     "nodemon": "^2.0.7",
     "rimraf": "^3.0.2",
     "set-cookie-parser": "^2.4.8",
-    "stream-to-string": "^1.2.0",
     "supertest": "^6.1.3",
     "ts-jest": "^27.0.3",
     "typedoc": "^0.21.2",
diff --git a/src/index.ts b/src/index.ts
index 1b6893593..844cc3c10 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -117,10 +117,10 @@ export * from './ldp/http/PreferenceParser';
 export * from './ldp/http/RawBodyParser';
 export * from './ldp/http/RequestParser';
 export * from './ldp/http/ResponseWriter';
+export * from './ldp/http/SafeErrorHandler';
 export * from './ldp/http/SparqlUpdateBodyParser';
 export * from './ldp/http/SparqlUpdatePatch';
 export * from './ldp/http/TargetExtractor';
-export * from './ldp/http/TextErrorHandler';
 
 // LDP/Operations
 export * from './ldp/operations/DeleteOperationHandler';
diff --git a/src/ldp/http/TextErrorHandler.ts b/src/ldp/http/SafeErrorHandler.ts
similarity index 56%
rename from src/ldp/http/TextErrorHandler.ts
rename to src/ldp/http/SafeErrorHandler.ts
index d93363a93..a49fee1cb 100644
--- a/src/ldp/http/TextErrorHandler.ts
+++ b/src/ldp/http/SafeErrorHandler.ts
@@ -1,4 +1,5 @@
-import { getStatusCode } from '../../util/errors/ErrorUtil';
+import { getLoggerFor } from '../../logging/LogUtil';
+import { createErrorMessage, getStatusCode } from '../../util/errors/ErrorUtil';
 import { guardedStreamFrom } from '../../util/StreamUtil';
 import { toLiteral } from '../../util/TermUtil';
 import { HTTP, XSD } from '../../util/Vocabularies';
@@ -9,17 +10,27 @@ import type { ResponseDescription } from './response/ResponseDescription';
 
 /**
  * Returns a simple text description of an error.
- * This class is mostly a failsafe in case all other solutions fail.
+ * This class is a failsafe in case the wrapped error handler fails.
  */
-export class TextErrorHandler extends ErrorHandler {
+export class SafeErrorHandler extends ErrorHandler {
+  protected readonly logger = getLoggerFor(this);
+
+  private readonly errorHandler: ErrorHandler;
   private readonly showStackTrace: boolean;
 
-  public constructor(showStackTrace = false) {
+  public constructor(errorHandler: ErrorHandler, showStackTrace = false) {
     super();
+    this.errorHandler = errorHandler;
     this.showStackTrace = showStackTrace;
   }
 
-  public async handle({ error }: ErrorHandlerArgs): Promise<ResponseDescription> {
+  public async handle(input: ErrorHandlerArgs): Promise<ResponseDescription> {
+    try {
+      return await this.errorHandler.handleSafe(input);
+    } catch (error: unknown) {
+      this.logger.debug(`Recovering from error handler failure: ${createErrorMessage(error)}`);
+    }
+    const { error } = input;
     const statusCode = getStatusCode(error);
     const metadata = new RepresentationMetadata('text/plain');
     metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer));
diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts
index b99b1c94d..defa8dc21 100644
--- a/src/storage/conversion/ChainedConverter.ts
+++ b/src/storage/conversion/ChainedConverter.ts
@@ -31,67 +31,6 @@ type MatchedPath = {
   weight: number;
 };
 
-/**
- * An LRU cache for storing `ConversionPath`s.
- */
-class LruPathCache {
-  private readonly maxSize: number;
-  // Contents are ordered from least to most recently used
-  private readonly paths: ConversionPath[] = [];
-
-  public constructor(maxSize: number) {
-    this.maxSize = maxSize;
-  }
-
-  /**
-   * Add the given path to the cache as most recently used.
-   */
-  public add(path: ConversionPath): void {
-    this.paths.push(path);
-    if (this.paths.length > this.maxSize) {
-      this.paths.shift();
-    }
-  }
-
-  /**
-   * Find a path that can convert the given type to the given preferences.
-   * Note that this finds the first matching path in the cache,
-   * not the best one, should there be multiple results.
-   * In practice this should almost never be the case though.
-   */
-  public find(inType: string, outPreferences: ValuePreferences): MatchedPath | undefined {
-    // Last element is most recently used so has more chance of being the correct one
-    for (let i = this.paths.length - 1; i >= 0; --i) {
-      const path = this.paths[i];
-      // Check if `inType` matches the input and `outPreferences` the output types of the path
-      const match = this.getMatchedPath(inType, outPreferences, path);
-      if (match) {
-        // Set matched path to most recent result in the cache
-        this.paths.splice(i, 1);
-        this.paths.push(path);
-        return match;
-      }
-    }
-  }
-
-  /**
-   * Calculates the weights and exact types when using the given path on the given type and preferences.
-   * Undefined if there is no match
-   */
-  private getMatchedPath(inType: string, outPreferences: ValuePreferences, path: ConversionPath):
-  MatchedPath | undefined {
-    const inWeight = getTypeWeight(inType, path.inTypes);
-    if (inWeight === 0) {
-      return;
-    }
-    const outMatch = getBestPreference(path.outTypes, outPreferences);
-    if (!outMatch) {
-      return;
-    }
-    return { path, inType, outType: outMatch.value, weight: inWeight * outMatch.weight };
-  }
-}
-
 /**
  * A meta converter that takes an array of other converters as input.
  * It chains these converters by finding a path of converters
@@ -99,39 +38,36 @@ class LruPathCache {
  * In case there are multiple paths, the one with the highest weight gets found.
  * Will error in case no path can be found.
  *
- * Generated paths get stored in an internal cache for later re-use on similar requests.
- * Note that due to this caching `RepresentationConverter`s
- * that change supported input/output types at runtime are not supported,
- * unless cache size is set to 0.
- *
  * This is not a TypedRepresentationConverter since the supported output types
  * might depend on what is the input content-type.
  *
+ * This converter should be the last in a WaterfallHandler if there are multiple,
+ * since it will try to convert any representation with a content-type.
+ *
  * Some suggestions on how this class can be even more optimized should this ever be needed in the future.
  * Most of these decrease computation time at the cost of more memory.
- *  - Subpaths that are generated could also be cached.
- *  - When looking for the next step, cached paths could also be considered.
  *  - The algorithm could start on both ends of a possible path and work towards the middle.
  *  - When creating a path, store the list of unused converters instead of checking every step.
+ *  - Caching: https://github.com/solid/community-server/issues/832
  */
 export class ChainedConverter extends RepresentationConverter {
   protected readonly logger = getLoggerFor(this);
 
   private readonly converters: TypedRepresentationConverter[];
-  private readonly cache: LruPathCache;
 
-  public constructor(converters: TypedRepresentationConverter[], maxCacheSize = 50) {
+  public constructor(converters: TypedRepresentationConverter[]) {
     super();
     if (converters.length === 0) {
       throw new Error('At least 1 converter is required.');
     }
     this.converters = [ ...converters ];
-    this.cache = new LruPathCache(maxCacheSize);
   }
 
   public async canHandle(input: RepresentationConverterArgs): Promise<void> {
-    // Will cache the path if found, and error if not
-    await this.findPath(input);
+    const type = input.representation.metadata.contentType;
+    if (!type) {
+      throw new BadRequestHttpError('Missing Content-Type header.');
+    }
   }
 
   public async handle(input: RepresentationConverterArgs): Promise<Representation> {
@@ -156,24 +92,15 @@ export class ChainedConverter extends RepresentationConverter {
     return path.converters.slice(-1)[0].handle(args);
   }
 
-  public async handleSafe(input: RepresentationConverterArgs): Promise<Representation> {
-    // This way we don't run `findPath` twice, even though it would be cached for the second call
-    return this.handle(input);
-  }
-
   private isMatchedPath(path: unknown): path is MatchedPath {
     return typeof (path as MatchedPath).path === 'object';
   }
 
   /**
-   * Finds a conversion path that can handle the given input,
-   * either in the cache or by generating a new one.
+   * Finds a conversion path that can handle the given input.
    */
   private async findPath(input: RepresentationConverterArgs): Promise<MatchedPath | ValuePreference> {
-    const type = input.representation.metadata.contentType;
-    if (!type) {
-      throw new BadRequestHttpError('Missing Content-Type header.');
-    }
+    const type = input.representation.metadata.contentType!;
     const preferences = cleanPreferences(input.preferences.type);
 
     const weight = getTypeWeight(type, preferences);
@@ -182,15 +109,7 @@ export class ChainedConverter extends RepresentationConverter {
       return { value: type, weight };
     }
 
-    // Use a cached solution if we have one.
-    // Note that it's possible that a better one could be generated.
-    // But this is usually highly unlikely.
-    let match = this.cache.find(type, preferences);
-    if (!match) {
-      match = await this.generatePath(type, preferences);
-      this.cache.add(match.path);
-    }
-    return match;
+    return this.generatePath(type, preferences);
   }
 
   /**


Test patch:
diff --git a/test/unit/ldp/http/SafeErrorHandler.test.ts b/test/unit/ldp/http/SafeErrorHandler.test.ts
new file mode 100644
index 000000000..6b92a6a19
--- /dev/null
+++ b/test/unit/ldp/http/SafeErrorHandler.test.ts
@@ -0,0 +1,77 @@
+import 'jest-rdf';
+import { DataFactory } from 'n3';
+import type { ErrorHandler } from '../../../../src/ldp/http/ErrorHandler';
+import { SafeErrorHandler } from '../../../../src/ldp/http/SafeErrorHandler';
+import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
+import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
+import { readableToString } from '../../../../src/util/StreamUtil';
+import { HTTP, XSD } from '../../../../src/util/Vocabularies';
+import literal = DataFactory.literal;
+
+describe('A SafeErrorHandler', (): void => {
+  let error: Error;
+  let stack: string | undefined;
+  let errorHandler: jest.Mocked<ErrorHandler>;
+  let handler: SafeErrorHandler;
+
+  beforeEach(async(): Promise<void> => {
+    error = new NotFoundHttpError('not here');
+    ({ stack } = error);
+
+    errorHandler = {
+      handleSafe: jest.fn().mockResolvedValue(new BasicRepresentation('<html>fancy error</html>', 'text/html')),
+    } as any;
+
+    handler = new SafeErrorHandler(errorHandler, true);
+  });
+
+  it('can handle everything.', async(): Promise<void> => {
+    await expect(handler.canHandle({} as any)).resolves.toBeUndefined();
+  });
+
+  it('sends the request to the stored error handler.', async(): Promise<void> => {
+    const prom = handler.handle({ error } as any);
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.metadata?.contentType).toBe('text/html');
+    await expect(readableToString(result.data!)).resolves.toBe('<html>fancy error</html>');
+  });
+
+  describe('where the wrapped error handler fails', (): void => {
+    beforeEach(async(): Promise<void> => {
+      errorHandler.handleSafe.mockRejectedValue(new Error('handler failed'));
+    });
+
+    it('creates a text representation of the error.', async(): Promise<void> => {
+      const prom = handler.handle({ error } as any);
+      await expect(prom).resolves.toBeDefined();
+      const result = await prom;
+      expect(result.statusCode).toBe(404);
+      expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
+      expect(result.metadata?.contentType).toBe('text/plain');
+      await expect(readableToString(result.data!)).resolves.toBe(`${stack}\n`);
+    });
+
+    it('concatenates name and message if there is no stack.', async(): Promise<void> => {
+      delete error.stack;
+      const prom = handler.handle({ error } as any);
+      await expect(prom).resolves.toBeDefined();
+      const result = await prom;
+      expect(result.statusCode).toBe(404);
+      expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
+      expect(result.metadata?.contentType).toBe('text/plain');
+      await expect(readableToString(result.data!)).resolves.toBe(`NotFoundHttpError: not here\n`);
+    });
+
+    it('hides the stack trace if the option is disabled.', async(): Promise<void> => {
+      handler = new SafeErrorHandler(errorHandler);
+      const prom = handler.handle({ error } as any);
+      await expect(prom).resolves.toBeDefined();
+      const result = await prom;
+      expect(result.statusCode).toBe(404);
+      expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
+      expect(result.metadata?.contentType).toBe('text/plain');
+      await expect(readableToString(result.data!)).resolves.toBe(`NotFoundHttpError: not here\n`);
+    });
+  });
+});
diff --git a/test/unit/ldp/http/TextErrorHandler.test.ts b/test/unit/ldp/http/TextErrorHandler.test.ts
deleted file mode 100644
index 7c9121f9e..000000000
--- a/test/unit/ldp/http/TextErrorHandler.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import 'jest-rdf';
-import { DataFactory } from 'n3';
-import stringifyStream from 'stream-to-string';
-import { TextErrorHandler } from '../../../../src/ldp/http/TextErrorHandler';
-import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
-import { HTTP, XSD } from '../../../../src/util/Vocabularies';
-import literal = DataFactory.literal;
-
-describe('A TextErrorHandler', (): void => {
-  // The error object can get modified by the handler
-  let error: Error;
-  let stack: string | undefined;
-  let handler: TextErrorHandler;
-
-  beforeEach(async(): Promise<void> => {
-    error = new NotFoundHttpError('not here');
-    ({ stack } = error);
-
-    handler = new TextErrorHandler(true);
-  });
-
-  it('can handle everything.', async(): Promise<void> => {
-    await expect(handler.canHandle({} as any)).resolves.toBeUndefined();
-  });
-
-  it('creates a text representation of the error.', async(): Promise<void> => {
-    const prom = handler.handle({ error } as any);
-    await expect(prom).resolves.toBeDefined();
-    const result = await prom;
-    expect(result.statusCode).toBe(404);
-    expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
-    expect(result.metadata?.contentType).toBe('text/plain');
-    const text = await stringifyStream(result.data!);
-    expect(text).toBe(`${stack}\n`);
-  });
-
-  it('concatenates name and message if there is no stack.', async(): Promise<void> => {
-    delete error.stack;
-    const prom = handler.handle({ error } as any);
-    await expect(prom).resolves.toBeDefined();
-    const result = await prom;
-    expect(result.statusCode).toBe(404);
-    expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
-    expect(result.metadata?.contentType).toBe('text/plain');
-    const text = await stringifyStream(result.data!);
-    expect(text).toBe(`NotFoundHttpError: not here\n`);
-  });
-
-  it('hides the stack trace if the option is disabled.', async(): Promise<void> => {
-    handler = new TextErrorHandler();
-    const prom = handler.handle({ error } as any);
-    await expect(prom).resolves.toBeDefined();
-    const result = await prom;
-    expect(result.statusCode).toBe(404);
-    expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
-    expect(result.metadata?.contentType).toBe('text/plain');
-    const text = await stringifyStream(result.data!);
-    expect(text).toBe(`NotFoundHttpError: not here\n`);
-  });
-});
diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts
index e4879fb01..6afc73b33 100644
--- a/test/unit/storage/conversion/ChainedConverter.test.ts
+++ b/test/unit/storage/conversion/ChainedConverter.test.ts
@@ -77,7 +77,7 @@ describe('A ChainedConverter', (): void => {
     const converter = new ChainedConverter(converters);
 
     args.representation.metadata.contentType = 'b/b';
-    await expect(converter.canHandle(args)).rejects
+    await expect(converter.handle(args)).rejects
       .toThrow('No conversion path could be made from b/b to x/x,x/*,internal/*.');
   });
 
@@ -218,104 +218,4 @@ describe('A ChainedConverter', (): void => {
     expect(converter.handle).toHaveBeenCalledTimes(1);
     expect(converter.handle).toHaveBeenLastCalledWith(args);
   });
-
-  it('caches paths for re-use.', async(): Promise<void> => {
-    const converters = [
-      new DummyConverter({ 'a/a': 0.8 }, { 'b/b': 0.9 }),
-      new DummyConverter({ 'b/b': 0.8 }, { 'x/x': 1 }),
-    ];
-    const converter = new ChainedConverter(converters);
-    let result = await converter.handle(args);
-    expect(result.metadata.contentType).toBe('x/x');
-
-    jest.spyOn(converters[0], 'getInputTypes');
-    jest.spyOn(converters[0], 'getOutputTypes');
-    result = await converter.handle(args);
-    expect(result.metadata.contentType).toBe('x/x');
-    expect(converters[0].getInputTypes).toHaveBeenCalledTimes(0);
-    expect(converters[0].getOutputTypes).toHaveBeenCalledTimes(0);
-  });
-
-  it('removes unused paths from the cache.', async(): Promise<void> => {
-    const converters = [
-      new DummyConverter({ 'a/a': 0.8 }, { 'b/b': 0.9 }),
-      new DummyConverter({ 'b/b': 0.8 }, { 'x/x': 1 }),
-      new DummyConverter({ 'c/c': 0.8 }, { 'b/b': 0.9 }),
-    ];
-    // Cache size 1
-    const converter = new ChainedConverter(converters, 1);
-    let result = await converter.handle(args);
-    expect(result.metadata.contentType).toBe('x/x');
-
-    // Should remove previous path (which contains converter 0)
-    args.representation.metadata.contentType = 'c/c';
-    result = await converter.handle(args);
-    expect(result.metadata.contentType).toBe('x/x');
-
-    jest.spyOn(converters[0], 'getInputTypes');
-    jest.spyOn(converters[0], 'getOutputTypes');
-    args.representation.metadata.contentType = 'a/a';
-    result = await converter.handle(args);
-    expect(result.metadata.contentType).toBe('x/x');
-    expect(converters[0].getInputTypes).not.toHaveBeenCalledTimes(0);
-    expect(converters[0].getOutputTypes).not.toHaveBeenCalledTimes(0);
-  });
-
-  it('keeps the most recently used paths in the cache.', async(): Promise<void> => {
-    const converters = [
-      new DummyConverter({ 'a/a': 1 }, { 'd/d': 1 }),
-      new DummyConverter({ 'b/b': 1 }, { 'd/d': 1 }),
-      new DummyConverter({ 'c/c': 1 }, { 'd/d': 1 }),
-      new DummyConverter({ 'd/d': 1 }, { 'x/x': 1 }),
-    ];
-    // Cache size 2
-    const converter = new ChainedConverter(converters, 2);
-    // Caches path 0
-    await converter.handle(args);
-
-    // Caches path 1
-    args.representation.metadata.contentType = 'b/b';
-    await converter.handle(args);
-
-    // Reset path 0 in cache
-    args.representation.metadata.contentType = 'a/a';
-    await converter.handle(args);
-
-    // Caches path 2 and removes 1
-    args.representation.metadata.contentType = 'c/c';
-    await converter.handle(args);
-
-    jest.spyOn(converters[0], 'getInputTypes');
-    jest.spyOn(converters[1], 'getInputTypes');
-    jest.spyOn(converters[2], 'getInputTypes');
-
-    // Path 0 and 2 should be cached now
-    args.representation.metadata.contentType = 'a/a';
-    await converter.handle(args);
-    expect(converters[0].getInputTypes).toHaveBeenCalledTimes(0);
-    args.representation.metadata.contentType = 'c/c';
-    await converter.handle(args);
-    expect(converters[2].getInputTypes).toHaveBeenCalledTimes(0);
-    args.representation.metadata.contentType = 'b/b';
-    await converter.handle(args);
-    expect(converters[1].getInputTypes).not.toHaveBeenCalledTimes(0);
-  });
-
-  it('does not use cached paths that match content-type but not preferences.', async(): Promise<void> => {
-    const converters = [
-      new DummyConverter({ 'a/a': 1 }, { 'b/b': 1 }),
-      new DummyConverter({ 'b/b': 1 }, { 'x/x': 1 }),
-      new DummyConverter({ 'a/a': 1 }, { 'c/c': 1 }),
-      new DummyConverter({ 'c/c': 1 }, { 'y/y': 1 }),
-    ];
-    const converter = new ChainedConverter(converters);
-
-    // Cache a-b-x path
-    await converter.handle(args);
-
-    // Generate new a-c-y path
-    args.preferences.type = { 'y/y': 1 };
-    const result = await converter.handle(args);
-    expect(result.metadata.contentType).toBe('y/y');
-  });
 });
diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts
index 35a3131b7..9571e8ed0 100644
--- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts
+++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts
@@ -1,6 +1,5 @@
 import { namedNode, triple } from '@rdfjs/data-model';
 import rdfSerializer from 'rdf-serialize';
-import stringifyStream from 'stream-to-string';
 import streamifyArray from 'streamify-array';
 import type { Representation } from '../../../../src/ldp/representation/Representation';
 import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
@@ -8,6 +7,7 @@ import type { RepresentationPreferences } from '../../../../src/ldp/representati
 import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
 import { QuadToRdfConverter } from '../../../../src/storage/conversion/QuadToRdfConverter';
 import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
+import { readableToString } from '../../../../src/util/StreamUtil';
 import { DC, PREFERRED_PREFIX_TERM } from '../../../../src/util/Vocabularies';
 
 describe('A QuadToRdfConverter', (): void => {
@@ -63,7 +63,7 @@ describe('A QuadToRdfConverter', (): void => {
       metadata: expect.any(RepresentationMetadata),
     });
     expect(result.metadata.contentType).toEqual('text/turtle');
-    await expect(stringifyStream(result.data)).resolves.toEqual(
+    await expect(readableToString(result.data)).resolves.toEqual(
       `<http://test.com/s> <http://test.com/p> <http://test.com/o>.
 `,
     );
@@ -83,7 +83,7 @@ describe('A QuadToRdfConverter', (): void => {
     const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result.metadata.contentType).toEqual('text/turtle');
-    await expect(stringifyStream(result.data)).resolves.toEqual(
+    await expect(readableToString(result.data)).resolves.toEqual(
       `@prefix dc: <http://purl.org/dc/terms/>.
 @prefix test: <http://test.com/>.
 
@@ -104,7 +104,7 @@ test:s dc:modified test:o.
     const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result.metadata.contentType).toEqual('text/turtle');
-    await expect(stringifyStream(result.data)).resolves.toEqual(
+    await expect(readableToString(result.data)).resolves.toEqual(
       `<> <#abc> <def/ghi>.
 `,
     );
@@ -127,7 +127,7 @@ test:s dc:modified test:o.
       metadata: expect.any(RepresentationMetadata),
     });
     expect(result.metadata.contentType).toEqual('application/ld+json');
-    await expect(stringifyStream(result.data)).resolves.toEqual(
+    await expect(readableToString(result.data)).resolves.toEqual(
       `[
   {
     "@id": "http://test.com/s",