// Copyright Inrupt Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
// Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

import {
  jest,
  describe,
  it,
  expect,
  beforeEach,
  afterEach,
} from "@jest/globals";
import { DEFAULT_TYPE } from "@inrupt/solid-client-errors";
import { FetchError } from "./resource";
import {
  getFile,
  deleteFile,
  saveFileInContainer,
  overwriteFile,
  flattenHeaders,
} from "./file";
import type { WithResourceInfo } from "../interfaces";
import { mockResponse } from "../tests.internal";

describe("flattenHeaders", () => {
  it("returns an empty object for undefined headers", () => {
    expect(flattenHeaders(undefined)).toEqual({});
  });

  it("returns well-formed headers as-is", () => {
    const headers: Record<string, string> = {
      test: "value",
    };
    expect(flattenHeaders(headers)).toEqual(headers);
  });

  it("transforms an incoming Headers object into a flat headers structure", () => {
    const myHeaders = new Headers();
    myHeaders.append("accept", "application/json");
    myHeaders.append("Content-Type", "text/turtle");
    const flatHeaders = flattenHeaders(myHeaders);
    expect(flatHeaders).toEqual({
      accept: "application/json",
      "content-type": "text/turtle",
    });
  });

  it("supports non-iterable headers if they provide a reasonably standard way of browsing them", () => {
    const myHeaders: any = {};
    myHeaders.forEach = (
      callback: (value: string, key: string) => void,
    ): void => {
      callback("application/json", "accept");
      callback("text/turtle", "Content-Type");
    };
    const flatHeaders = flattenHeaders(myHeaders);
    expect(flatHeaders).toEqual({
      accept: "application/json",
      "Content-Type": "text/turtle",
    });
  });

  it("transforms an incoming string[][] array into a flat headers structure", () => {
    const myHeaders: string[][] = [
      ["accept", "application/json"],
      ["Content-Type", "text/turtle"],
    ];
    const flatHeaders = flattenHeaders(myHeaders);
    expect(flatHeaders).toEqual({
      accept: "application/json",
      "Content-Type": "text/turtle",
    });
  });
});

describe("getFile", () => {
  it("should GET a remote resource using the included fetcher if no other fetcher is available", async () => {
    jest
      .spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(
        new Response("Some data", { status: 200, statusText: "OK" }),
      );
    await getFile("https://example.org/resource");
    expect(fetch).toHaveBeenCalledWith(
      "https://example.org/resource",
      undefined,
    );
    expect(fetch).toHaveBeenCalledTimes(1);
  });

  it("should GET a remote resource using the provided fetcher", async () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      .mockResolvedValue(
        new Response("Some data", { status: 200, statusText: "OK" }),
      );

    await getFile("https://example.org/resource", {
      fetch: mockFetch,
    });

    expect(mockFetch.mock.calls).toEqual([
      ["https://example.org/resource", undefined],
    ]);
  });

  describe("normalizing the target URL", () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      // Mock the implementation instead of the resolved value
      // so that the body isn't consumed.
      .mockImplementation(
        async () =>
          new Response("Some data", { status: 200, statusText: "OK" }),
      );

    it("removes double slashes from path", async () => {
      await getFile("https://some.pod//resource//path", {
        fetch: mockFetch,
      });

      expect(mockFetch.mock.calls).toHaveLength(1);
      expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource/path");
    });

    it("enforces the target URL doesn't have a trailing slash", async () => {
      await getFile("https://some.pod/container/", {
        fetch: mockFetch,
      });

      await getFile("https://some.pod/resource", {
        fetch: mockFetch,
      });

      expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container");
      expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource");
    });

    it("removes relative path components", async () => {
      await getFile("https://some.pod/././test/../resource", {
        fetch: mockFetch,
      });

      expect(mockFetch.mock.calls).toHaveLength(1);
      expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource");
    });
  });

  it("should return the fetched data as a blob", async () => {
    const mockedResponse = new Response("Some data", {
      status: 200,
      statusText: "OK",
    });
    jest
      .spyOn(mockedResponse, "url", "get")
      .mockReturnValue("https://example.org/resource");

    const mockFetch = jest
      .fn<typeof fetch>()
      .mockReturnValue(Promise.resolve(mockedResponse));

    const file = await getFile("https://example.org/resource", {
      fetch: mockFetch,
    });

    expect(file.internal_resourceInfo.sourceIri).toBe(
      "https://example.org/resource",
    );
    expect(file.internal_resourceInfo.contentType).toContain("text/plain");
    expect(file.internal_resourceInfo.isRawData).toBe(true);

    const fileData = await file.text();
    expect(fileData).toBe("Some data");
  });

  it("should pass the request headers through", async () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      .mockResolvedValue(
        new Response("Some data", { status: 200, statusText: "OK" }),
      );

    await getFile("https://example.org/resource", {
      init: {
        headers: new Headers({ Accept: "text/turtle" }),
      },
      fetch: mockFetch,
    });

    expect(mockFetch.mock.calls).toEqual([
      [
        "https://example.org/resource",
        {
          headers: new Headers({ Accept: "text/turtle" }),
        },
      ],
    ]);
  });

  it("should throw on failure", async () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      .mockResolvedValue(
        new Response(undefined, { status: 400, statusText: "Bad request" }),
      );

    const response = getFile("https://example.org/resource", {
      fetch: mockFetch,
    });
    await expect(response).rejects.toThrow(
      "Fetching the File failed: [400] [Bad request]",
    );
  });

  it("includes the status code, status text and response body when a request failed", async () => {
    const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
      new Response("Teapots don't make coffee.", {
        status: 418,
        statusText: "I'm a teapot!",
      }),
    );

    const response = getFile("https://arbitrary.url", {
      fetch: mockFetch,
    });
    await expect(response).rejects.toMatchObject({
      statusCode: 418,
      statusText: "I'm a teapot!",
      message: expect.stringMatching("Teapots don't make coffee"),
    });
  });

  it("throws an instance of FetchError when a request failed", async () => {
    const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
      new Response("I'm a teapot!", {
        status: 418,
        statusText: "I'm a teapot!",
      }),
    );

    const fetchPromise = getFile("https://arbitrary.pod/resource", {
      fetch: mockFetch,
    });

    const error: FetchError = await fetchPromise.catch((err) => err);

    expect(error).toBeInstanceOf(FetchError);

    // Verify the problem details data
    expect(error.problemDetails.type).toBe(DEFAULT_TYPE);
    expect(error.problemDetails.title).toBe("I'm a teapot!");
    expect(error.problemDetails.status).toBe(418);
    expect(error.problemDetails.detail).toBeUndefined();
    expect(error.problemDetails.instance).toBeUndefined();
  });

  it("throws an instance of FetchError when a request failed with problem details", async () => {
    const url = "https://arbitrary.pod/resource";
    const problem = {
      type: new URL("https://error.test/NotFound"),
      title: "Not Found",
      detail: "No resource was found at this location",
      status: 404,
      instance: new URL(url),
    };
    const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
      mockResponse(
        JSON.stringify(problem),
        {
          status: 404,
          statusText: "Not Found",
          headers: {
            "Content-Type": "application/problem+json",
          },
        },
        url,
      ),
    );

    const fetchPromise = getFile(url, {
      fetch: mockFetch,
    });

    const error: FetchError = await fetchPromise.catch((err) => err);

    expect(error).toBeInstanceOf(FetchError);

    // Verify the problem details data
    expect(error.problemDetails.type).toEqual(problem.type);
    expect(error.problemDetails.title).toBe(problem.title);
    expect(error.problemDetails.status).toBe(problem.status);
    expect(error.problemDetails.detail).toBe(problem.detail);
    expect(error.problemDetails.instance).toEqual(problem.instance);
  });

  it("throws an instance of FetchError when a request failed with problem details using relative URIs", async () => {
    const url = "https://arbitrary.pod/container/resource";
    const problem = {
      type: "/errors/NotFound",
      title: "Not Found",
      detail: "No resource was found at this location",
      status: 404,
      instance: "relative-url",
    };
    const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
      mockResponse(
        JSON.stringify(problem),
        {
          status: 404,
          statusText: "Not Found",
          headers: {
            "Content-Type": "application/problem+json",
          },
        },
        url,
      ),
    );

    const fetchPromise = getFile(url, {
      fetch: mockFetch,
    });

    const error: FetchError = await fetchPromise.catch((err) => err);

    expect(error).toBeInstanceOf(FetchError);

    // Verify the problem details data
    expect(error.problemDetails.type).toEqual(
      new URL("https://arbitrary.pod/errors/NotFound"),
    );
    expect(error.problemDetails.title).toBe(problem.title);
    expect(error.problemDetails.status).toBe(problem.status);
    expect(error.problemDetails.detail).toBe(problem.detail);
    expect(error.problemDetails.instance).toEqual(
      new URL("https://arbitrary.pod/container/relative-url"),
    );
  });
});

describe("Non-RDF data deletion", () => {
  it("should DELETE a remote resource using the included fetcher if no other fetcher is available", async () => {
    jest
      .spyOn(globalThis, "fetch")
      .mockResolvedValueOnce(
        new Response(undefined, { status: 200, statusText: "Deleted" }),
      );

    const response = await deleteFile("https://example.org/resource");

    expect(fetch).toHaveBeenCalledWith("https://example.org/resource", {
      method: "DELETE",
    });
    expect(response).toBeUndefined();
  });

  it("should DELETE a remote resource using the provided fetcher", async () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      .mockResolvedValue(
        new Response(undefined, { status: 200, statusText: "Deleted" }),
      );

    const response = await deleteFile("https://example.org/resource", {
      fetch: mockFetch,
    });

    expect(mockFetch.mock.calls).toEqual([
      [
        "https://example.org/resource",
        {
          method: "DELETE",
        },
      ],
    ]);
    expect(response).toBeUndefined();
  });

  describe("normalizing the target URL", () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      // Mock the implementation instead of the resolved value
      // so that the body isn't consumed.
      .mockImplementation(
        async () =>
          new Response("Some data", { status: 200, statusText: "OK" }),
      );

    it("removes double slashes from path", async () => {
      await deleteFile("https://some.pod//resource//path", {
        fetch: mockFetch,
      });

      expect(mockFetch.mock.calls).toHaveLength(1);
      expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource/path");
    });

    it("enforces the target URL doesn't have a trailing slash", async () => {
      await deleteFile("https://some.pod/container/", {
        fetch: mockFetch,
      });

      await deleteFile("https://some.pod/resource", {
        fetch: mockFetch,
      });

      expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container");
      expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource");
    });

    it("removes relative path components", async () => {
      await deleteFile("https://some.pod/././test/../resource", {
        fetch: mockFetch,
      });

      expect(mockFetch.mock.calls).toHaveLength(1);
      expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource");
    });
  });

  it("should accept a fetched File as target", async () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      .mockResolvedValue(
        new Response(undefined, { status: 200, statusText: "Deleted" }),
      );

    const mockFile: WithResourceInfo = {
      internal_resourceInfo: {
        isRawData: true,
        sourceIri: "https://example.org/resource",
      },
    };

    const response = await deleteFile(mockFile, {
      fetch: mockFetch,
    });

    expect(mockFetch.mock.calls).toEqual([
      [
        "https://example.org/resource",
        {
          method: "DELETE",
        },
      ],
    ]);
    expect(response).toBeUndefined();
  });

  it("should pass through the request init if it is set by the user", async () => {
    const mockFetch = jest
      .fn<typeof fetch>()
      .mockResolvedValue(
        new Response(undefined, { status: 200, statusText: "Deleted" }),
      );

    await deleteFile("https://example.org/resource", {
      fetch: mockFetch,
      init: {
        mode: "same-origin",
      },
    });

    expect(mockFetch.mock.calls).toEqual([
      [
        "https://example.org/resource",
        {
          method: "DELETE",
          mode: "same-origin",
        },
      ],
    ]);
  });
  it("should throw an error on a failed request", async () => {
    const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
      new Response(undefined, {
        status: 400,
        statusText: "Bad request",
      }),
    );

    const deletionPromise = deleteFile("https://example.org/resource", {
      fetch: mockFetch,
    });

    await expect(deletionPromise).rejects.toThrow(
      "Deleting the file at [https://example.org/resource] failed: [400] [Bad request]",
    );
  });
  it("includes the status code, status message and response body when a request failed", async () => {
    const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
      new Response("Teapots don't make coffee", {
        status: 418,
        statusText: "I'm a teapot!",
      }),
    );

    const deletionPromise = deleteFile("https://arbitrary.url", {
      fetch: mockFetch,
    });

    await expect(deletionPromise).rejects.toMatchObject({
      statusCode: 418,
      statusText: "I'm a teapot!",
      message: expect.stringMatching("Teapots don't make coffee"),
    });
  });
});

describe("Write non-RDF data into a folder", () => {
  const mockBlob = new Blob(["mock blob data"], { type: "binary" });
  const mockFile = new File(["mock blob data"], "myFile.txt", {
    type: "binary",
  });

  beforeEach(() => {
    jest.spyOn(globalThis, "fetch").mockImplementation(
      async (info) =>
        new Response(undefined, {
          status: 201,
          statusText: "Created",
          headers: { Location: new URL("someFileName", info.toString()).href },
        }),
    );
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  describe.each([
    ["blob", mockBlob],
    ["file", mockFile],
  ])("support for %s raw data source", (type, data) => {
    it("should default to the included fetcher if no other is available", async () => {
      await saveFileInContainer("https://example.org/resource", data);

      expect(fetch).toHaveBeenCalledTimes(1);
    });

    it("should POST to a remote resource using the included fetcher, and return the saved file", async () => {
      const savedFile = await saveFileInContainer(
        "https://example.org/container/",
        data,
      );
      expect(fetch).toHaveBeenCalledTimes(1);
      expect(fetch).toHaveBeenCalledWith("https://example.org/container/", {
        headers: {
          "Content-Type": "binary",
          Slug: type === "file" ? "myFile.txt" : undefined,
        },
        method: "POST",
        body: data,
      });

      if (mockBlob === data) {
        expect(savedFile).toBeInstanceOf(Blob);
      }
      expect(savedFile!.internal_resourceInfo).toEqual({
        contentType: "binary",
        sourceIri: "https://example.org/container/someFileName",
        isRawData: true,
      });
    });

    it("should use the provided fetcher if available", async () => {
      const mockFetch = jest.fn<typeof fetch>(
        async () =>
          new Response(null, {
            headers: { Location: "/container/resource" },
          }),
      );

      await saveFileInContainer("https://example.org/container/", data, {
        fetch: mockFetch,
      });

      expect(mockFetch).toHaveBeenCalledTimes(1);
      expect(mockFetch).toHaveBeenCalledWith("https://example.org/container/", {
        headers: {
          "Content-Type": "binary",
          Slug: type === "file" ? "myFile.txt" : undefined,
        },
        method: "POST",
        body: data,
      });
      expect(fetch).not.toHaveBeenCalled();
    });

    describe("normalizing the target URL", () => {
      it("removes double slashes from path", async () => {
        const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
          new Response(undefined, {
            headers: { Location: "https://arbitrary.pod/resource" },
          }),
        );

        await saveFileInContainer(
          "https://some.pod//container//path///",
          data,
          {
            fetch: mockFetch,
          },
        );

        expect(mockFetch.mock.calls).toHaveLength(1);
        expect(mockFetch.mock.calls[0][0]).toBe(
          "https://some.pod/container/path/",
        );
      });

      it("enforces a trailing slash is present", async () => {
        const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
          new Response(undefined, {
            headers: { Location: "https://arbitrary.pod/resource" },
          }),
        );

        await saveFileInContainer("https://some.pod/container", data, {
          fetch: mockFetch,
        });

        expect(mockFetch.mock.calls).toHaveLength(1);
        expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/");
      });

      it("removes relative path components", async () => {
        const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
          new Response(undefined, {
            headers: { Location: "https://arbitrary.pod/resource" },
          }),
        );

        await saveFileInContainer(
          "https://some.pod/././container/test/../",
          data,
          {
            fetch: mockFetch,
          },
        );

        expect(mockFetch.mock.calls).toHaveLength(1);
        expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/container/");
      });
    });

    it("should pass the suggested slug through", async () => {
      const mockFetch = jest.fn<typeof fetch>(
        async () =>
          new Response(null, {
            headers: { Location: "/container/resource" },
          }),
      );

      await saveFileInContainer("https://example.org/container/", data, {
        fetch: mockFetch,
        slug: "someFileName",
      });

      expect(mockFetch).toHaveBeenCalledTimes(1);
      expect(mockFetch).toHaveBeenCalledWith("https://example.org/container/", {
        headers: {
          "Content-Type": "binary",
          Slug: "someFileName",
        },
        method: "POST",
        body: data,
      });
      expect(fetch).not.toHaveBeenCalled();
    });

    it("throws when a reserved header is passed", async () => {
      await expect(
        saveFileInContainer("https://example.org/container/", data, {
          fetch: async () => new Response(),
          init: {
            headers: {
              Slug: "someFileName",
            },
          },
        }),
      ).rejects.toThrow(/reserved header/);
    });

    it("throws when saving failed", async () => {
      await expect(
        saveFileInContainer("https://example.org/container/", data, {
          fetch: async () =>
            new Response(undefined, { status: 403, statusText: "Forbidden" }),
        }),
      ).rejects.toThrow(
        "Saving the file in [https://example.org/container/] failed: [403] [Forbidden]",
      );
    });

    it("throws when the server did not return the location of the newly-saved file", async () => {
      await expect(
        saveFileInContainer("https://example.org/container/", data, {
          fetch: async () =>
            new Response(undefined, { status: 201, statusText: "Created" }),
        }),
      ).rejects.toThrow(
        "Could not determine the location of the newly saved file.",
      );
    });

    it("includes the status code, status message and response body when a request failed", async () => {
      await expect(
        saveFileInContainer("https://arbitrary.url", data, {
          fetch: async () =>
            new Response("Teapots don't make coffee", {
              status: 418,
              statusText: "I'm a teapot!",
            }),
        }),
      ).rejects.toMatchObject({
        statusCode: 418,
        statusText: "I'm a teapot!",
        message: expect.stringMatching("Teapots don't make coffee"),
      });
    });
  });

  it("sets the correct Content Type on the returned file, if available", async () => {
    const mockTextBlob = new Blob(["mock blob data"], {
      type: "text/plain",
    });
    const savedFile = await saveFileInContainer(
      "https://example.org/container/",
      mockTextBlob,
    );

    expect(savedFile).toBeInstanceOf(Blob);
    expect(savedFile!.internal_resourceInfo.contentType).toBe("text/plain");
  });

  it("sets the given Content Type on the returned file, if any was given", async () => {
    const mockTextBlob = new Blob(["mock blob data"], {
      type: "text/plain",
    });
    const savedFile = await saveFileInContainer(
      "https://example.org/container/",
      mockTextBlob,
      {
        contentType: "text/csv",
      },
    );

    expect(savedFile).toBeInstanceOf(Blob);
    expect(savedFile!.internal_resourceInfo.contentType).toBe("text/csv");
  });

  it("defaults the Content Type to `application/octet-stream` if none is known", async () => {
    const mockTextBlob = new Blob(["mock blob data"]);
    const savedFile = await saveFileInContainer(
      "https://example.org/container/",
      mockTextBlob,
    );

    expect(savedFile).toBeInstanceOf(Blob);
    expect(savedFile!.internal_resourceInfo.contentType).toBe(
      "application/octet-stream",
    );
  });
});

describe("Write non-RDF data directly into a resource (potentially erasing previous value)", () => {
  const mockBlob = new Blob(["mock blob data"], { type: "binary" });
  const mockFile = new File(["mock blob data"], "myFile.txt", {
    type: "binary",
  });

  beforeEach(() => {
    jest.spyOn(globalThis, "fetch").mockImplementation(
      async () =>
        new Response(undefined, {
          status: 201,
          statusText: "Created",
        }),
    );
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  describe.each([
    ["blob", mockBlob],
    ["file", mockFile],
  ])("support for %s raw data source", (type, data) => {
    it("should default to the included fetcher if no other fetcher is available", async () => {
      await overwriteFile("https://example.org/resource", data);

      expect(fetch).toHaveBeenCalledTimes(1);
    });

    it("should PUT to a remote resource when using the included fetcher, and return the saved file", async () => {
      jest.spyOn(globalThis, "fetch").mockResolvedValueOnce(
        new Response(undefined, {
          status: 201,
          statusText: "Created",
          url: "https://example.org/resource",
        } as ResponseInit),
      );

      const savedFile = await overwriteFile(
        "https://example.org/resource",
        data,
      );
      expect(fetch).toHaveBeenCalledTimes(1);
      expect(fetch).toHaveBeenCalledWith("https://example.org/resource", {
        headers: {
          "Content-Type": "binary",
          Slug: type === "file" ? "myFile.txt" : undefined,
        },
        method: "PUT",
        body: data,
      });
      if (mockBlob === data) {
        expect(savedFile).toBeInstanceOf(Blob);
      }
      expect(savedFile.internal_resourceInfo).toEqual({
        contentType: undefined,
        sourceIri: "https://example.org/resource",
        isRawData: true,
        linkedResources: {},
      });
    });

    it("should use the provided fetcher", async () => {
      const mockFetch = jest.fn<typeof fetch>(
        async () =>
          new Response(undefined, { status: 201, statusText: "Created" }),
      );

      await overwriteFile("https://example.org/resource", data, {
        fetch: mockFetch,
      });

      expect(mockFetch).toHaveBeenCalled();
    });

    it("should PUT a remote resource using the provided fetcher, and return the saved file", async () => {
      const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
        new Response(undefined, {
          status: 201,
          statusText: "Created",
          url: "https://example.org/resource",
        } as ResponseInit),
      );

      const savedFile = await overwriteFile(
        "https://example.org/resource",
        data,
        {
          fetch: mockFetch,
        },
      );

      expect(mockFetch).toHaveBeenCalledTimes(1);
      expect(mockFetch).toHaveBeenCalledWith("https://example.org/resource", {
        headers: expect.objectContaining({
          "Content-Type": "binary",
        }),
        method: "PUT",
        body: data,
      });

      if (mockBlob === data) {
        expect(savedFile).toBeInstanceOf(Blob);
      }
      expect(savedFile.internal_resourceInfo).toEqual({
        contentType: undefined,
        sourceIri: "https://example.org/resource",
        isRawData: true,
        linkedResources: {},
      });
    });

    it("throws when saving failed", async () => {
      const mockFetch = jest
        .fn<typeof fetch>()
        .mockResolvedValue(
          new Response(undefined, { status: 403, statusText: "Forbidden" }),
        );

      await expect(
        overwriteFile("https://example.org/resource", data, {
          fetch: mockFetch,
        }),
      ).rejects.toThrow(
        "Overwriting the file at [https://example.org/resource] failed: [403] [Forbidden]",
      );
    });

    it("includes the status code, status message and response body when a request failed", async () => {
      const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(
        new Response("Teapots don't make coffee", {
          status: 418,
          statusText: "I'm a teapot!",
        }),
      );

      await expect(
        overwriteFile("https://arbitrary.url", data, {
          fetch: mockFetch,
        }),
      ).rejects.toMatchObject({
        statusCode: 418,
        statusText: "I'm a teapot!",
        message: expect.stringContaining("Teapots don't make coffee"),
      });
    });
  });
});
