UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

931 lines (810 loc) • 28.4 kB
// 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"), }); }); }); });