branch:
asset-handler.test.ts
25180 bytesRaw
import { describe, it, expect } from "vitest";
import {
  handleAssetRequest,
  buildAssetManifest,
  buildAssets,
  createMemoryStorage,
  normalizeConfig,
  computeETag
} from "../asset-handler";
import type {
  AssetConfig,
  AssetManifest,
  AssetStorage
} from "../asset-handler";
import { inferContentType, isTextContentType } from "../mime";

// ── Helper: build manifest + storage from assets ────────────────────

async function makeAssets(
  assets: Record<string, string | ArrayBuffer>
): Promise<{ manifest: AssetManifest; storage: AssetStorage }> {
  return buildAssets(assets);
}

// ── MIME type tests ─────────────────────────────────────────────────

describe("inferContentType", () => {
  it("returns correct MIME type for common extensions", () => {
    expect(inferContentType("/index.html")).toBe("text/html; charset=utf-8");
    expect(inferContentType("/app.js")).toBe(
      "application/javascript; charset=utf-8"
    );
    expect(inferContentType("/styles.css")).toBe("text/css; charset=utf-8");
    expect(inferContentType("/data.json")).toBe(
      "application/json; charset=utf-8"
    );
    expect(inferContentType("/logo.png")).toBe("image/png");
    expect(inferContentType("/photo.jpg")).toBe("image/jpeg");
    expect(inferContentType("/icon.svg")).toBe("image/svg+xml");
    expect(inferContentType("/favicon.ico")).toBe("image/x-icon");
    expect(inferContentType("/font.woff2")).toBe("font/woff2");
    expect(inferContentType("/doc.pdf")).toBe("application/pdf");
    expect(inferContentType("/app.wasm")).toBe("application/wasm");
  });

  it("is case-insensitive for extensions", () => {
    expect(inferContentType("/FILE.HTML")).toBe("text/html; charset=utf-8");
    expect(inferContentType("/SCRIPT.JS")).toBe(
      "application/javascript; charset=utf-8"
    );
  });

  it("returns undefined for unknown extensions", () => {
    expect(inferContentType("/file.xyz")).toBeUndefined();
    expect(inferContentType("/noext")).toBeUndefined();
  });

  it("handles paths with multiple dots", () => {
    expect(inferContentType("/app.bundle.js")).toBe(
      "application/javascript; charset=utf-8"
    );
    expect(inferContentType("/styles.min.css")).toBe("text/css; charset=utf-8");
  });
});

describe("isTextContentType", () => {
  it("returns true for text types", () => {
    expect(isTextContentType("text/html; charset=utf-8")).toBe(true);
    expect(isTextContentType("text/css; charset=utf-8")).toBe(true);
    expect(isTextContentType("application/json; charset=utf-8")).toBe(true);
    expect(isTextContentType("application/javascript; charset=utf-8")).toBe(
      true
    );
    expect(isTextContentType("image/svg+xml")).toBe(true);
  });

  it("returns false for binary types", () => {
    expect(isTextContentType("image/png")).toBe(false);
    expect(isTextContentType("image/jpeg")).toBe(false);
    expect(isTextContentType("font/woff2")).toBe(false);
    expect(isTextContentType("application/pdf")).toBe(false);
    expect(isTextContentType("application/wasm")).toBe(false);
  });
});

// ── ETag computation tests ──────────────────────────────────────────

describe("computeETag", () => {
  it("returns a string for text content", async () => {
    const etag = await computeETag("hello world");
    expect(typeof etag).toBe("string");
    expect(etag.length).toBeGreaterThan(0);
  });

  it("returns consistent values for same content", async () => {
    const a = await computeETag("hello");
    const b = await computeETag("hello");
    expect(a).toBe(b);
  });

  it("returns different values for different content", async () => {
    const a = await computeETag("hello");
    const b = await computeETag("world");
    expect(a).not.toBe(b);
  });

  it("works with ArrayBuffer", async () => {
    const buf = new TextEncoder().encode("hello").buffer;
    const etag = await computeETag(buf);
    expect(typeof etag).toBe("string");
    expect(etag.length).toBeGreaterThan(0);
  });
});

// ── buildAssetManifest tests ────────────────────────────────────────

describe("buildAssetManifest", () => {
  it("builds a manifest from path->content", async () => {
    const manifest = await buildAssetManifest({
      "/index.html": "<h1>Hello</h1>",
      "/app.js": "console.log('hi')"
    });
    expect(manifest.size).toBe(2);
    expect(manifest.get("/index.html")).toBeDefined();
    expect(manifest.get("/app.js")).toBeDefined();
  });

  it("infers content types", async () => {
    const manifest = await buildAssetManifest({
      "/index.html": "<h1>Hello</h1>",
      "/app.js": "console.log('hi')",
      "/unknown": "data"
    });
    expect(manifest.get("/index.html")?.contentType).toBe(
      "text/html; charset=utf-8"
    );
    expect(manifest.get("/app.js")?.contentType).toBe(
      "application/javascript; charset=utf-8"
    );
    expect(manifest.get("/unknown")?.contentType).toBeUndefined();
  });

  it("computes etags", async () => {
    const manifest = await buildAssetManifest({
      "/a.html": "hello",
      "/b.html": "world"
    });
    expect(manifest.get("/a.html")?.etag).toBeDefined();
    expect(manifest.get("/b.html")?.etag).toBeDefined();
    expect(manifest.get("/a.html")?.etag).not.toBe(
      manifest.get("/b.html")?.etag
    );
  });
});

// ── createMemoryStorage tests ───────────────────────────────────────

describe("createMemoryStorage", () => {
  it("returns content for known pathnames", async () => {
    const storage = createMemoryStorage({ "/a.txt": "hello" });
    expect(await storage.get("/a.txt")).toBe("hello");
  });

  it("returns null for unknown pathnames", async () => {
    const storage = createMemoryStorage({ "/a.txt": "hello" });
    expect(await storage.get("/missing")).toBeNull();
  });
});

// ── handleAssetRequest tests ────────────────────────────────────────

describe("handleAssetRequest — basic serving", () => {
  it("serves an exact-match asset with correct content type", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<!DOCTYPE html><h1>Hi</h1>",
      "/app.js": "console.log('hello')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/app.js"),
      manifest,
      storage
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(res!.headers.get("Content-Type")).toBe(
      "application/javascript; charset=utf-8"
    );
    expect(await res!.text()).toBe("console.log('hello')");
  });

  it("serves HTML with correct content type", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/"),
      manifest,
      storage
    );
    expect(res).not.toBeNull();
    expect(res!.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
  });

  it("returns null for non-existent assets (fall-through)", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/api/data"),
      manifest,
      storage,
      { html_handling: "none", not_found_handling: "none" }
    );
    expect(res).toBeNull();
  });

  it("returns null for POST requests (fall-through)", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/index.html", { method: "POST" }),
      manifest,
      storage
    );
    expect(res).toBeNull();
  });

  it("handles HEAD requests with no body", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.js": "console.log('hello')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/app.js", { method: "HEAD" }),
      manifest,
      storage
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(res!.body).toBeNull();
    expect(res!.headers.get("Content-Type")).toBe(
      "application/javascript; charset=utf-8"
    );
  });
});

// ── ETag / 304 tests ────────────────────────────────────────────────

describe("handleAssetRequest — ETag and conditional requests", () => {
  it("includes ETag header in response", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.js": "console.log('hello')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/app.js"),
      manifest,
      storage
    );
    expect(res).not.toBeNull();
    const etag = res!.headers.get("ETag");
    expect(etag).toBeTruthy();
    expect(etag!.startsWith('"')).toBe(true);
    expect(etag!.endsWith('"')).toBe(true);
  });

  it("returns 304 when If-None-Match matches strong ETag", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.js": "console.log('hello')"
    });

    const first = await handleAssetRequest(
      new Request("http://example.com/app.js"),
      manifest,
      storage
    );
    const etag = first!.headers.get("ETag")!;

    const second = await handleAssetRequest(
      new Request("http://example.com/app.js", {
        headers: { "If-None-Match": etag }
      }),
      manifest,
      storage
    );
    expect(second).not.toBeNull();
    expect(second!.status).toBe(304);
    expect(second!.body).toBeNull();
  });

  it("returns 304 when If-None-Match matches weak ETag", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.js": "console.log('hello')"
    });

    const first = await handleAssetRequest(
      new Request("http://example.com/app.js"),
      manifest,
      storage
    );
    const strongEtag = first!.headers.get("ETag")!;
    const weakEtag = `W/${strongEtag}`;

    const second = await handleAssetRequest(
      new Request("http://example.com/app.js", {
        headers: { "If-None-Match": weakEtag }
      }),
      manifest,
      storage
    );
    expect(second).not.toBeNull();
    expect(second!.status).toBe(304);
  });

  it("returns 200 when If-None-Match does not match", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.js": "console.log('hello')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/app.js", {
        headers: { "If-None-Match": '"stale-etag"' }
      }),
      manifest,
      storage
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
  });
});

// ── Cache-Control tests ─────────────────────────────────────────────

describe("handleAssetRequest — Cache-Control", () => {
  it("sets must-revalidate for HTML files", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/"),
      manifest,
      storage
    );
    expect(res!.headers.get("Cache-Control")).toBe(
      "public, max-age=0, must-revalidate"
    );
  });

  it("sets immutable for hashed assets", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.a1b2c3d4.js": "console.log('versioned')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/app.a1b2c3d4.js"),
      manifest,
      storage
    );
    expect(res!.headers.get("Cache-Control")).toBe(
      "public, max-age=31536000, immutable"
    );
  });

  it("sets must-revalidate for non-hashed JS", async () => {
    const { manifest, storage } = await makeAssets({
      "/app.js": "console.log('hello')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/app.js"),
      manifest,
      storage
    );
    expect(res!.headers.get("Cache-Control")).toBe(
      "public, max-age=0, must-revalidate"
    );
  });
});

// ── SPA fallback tests ──────────────────────────────────────────────

describe("handleAssetRequest — SPA fallback", () => {
  it("serves /index.html for unknown routes with SPA config", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<!DOCTYPE html><div id='root'></div>",
      "/app.js": "console.log('app')"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/dashboard/settings", {
        headers: { Accept: "text/html,application/xhtml+xml" }
      }),
      manifest,
      storage,
      { not_found_handling: "single-page-application" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(await res!.text()).toBe("<!DOCTYPE html><div id='root'></div>");
  });

  it("does NOT serve SPA fallback for non-HTML requests (API calls)", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<!DOCTYPE html><div id='root'></div>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/api/counter"),
      manifest,
      storage,
      { not_found_handling: "single-page-application" }
    );
    expect(res).toBeNull();
  });

  it("still serves exact matches over SPA fallback", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>",
      "/about.html": "<h1>About</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/about"),
      manifest,
      storage,
      { not_found_handling: "single-page-application" }
    );
    expect(res).not.toBeNull();
    expect(await res!.text()).toBe("<h1>About</h1>");
  });

  it("falls through for unknown routes without SPA config", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/api/data"),
      manifest,
      storage,
      { html_handling: "none", not_found_handling: "none" }
    );
    expect(res).toBeNull();
  });
});

// ── 404.html handling tests ─────────────────────────────────────────

describe("handleAssetRequest — 404-page handling", () => {
  it("serves /404.html for unknown routes", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>",
      "/404.html": "<h1>Not Found</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/unknown"),
      manifest,
      storage,
      { not_found_handling: "404-page" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(404);
    expect(await res!.text()).toBe("<h1>Not Found</h1>");
  });

  it("walks up directory tree for nested 404.html", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>",
      "/blog/404.html": "<h1>Blog Not Found</h1>",
      "/404.html": "<h1>Root Not Found</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/blog/nonexistent"),
      manifest,
      storage,
      { not_found_handling: "404-page" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(404);
    expect(await res!.text()).toBe("<h1>Blog Not Found</h1>");
  });

  it("falls back to root 404.html if nested one is missing", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>",
      "/404.html": "<h1>Root Not Found</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/blog/nonexistent"),
      manifest,
      storage,
      { not_found_handling: "404-page" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(404);
    expect(await res!.text()).toBe("<h1>Root Not Found</h1>");
  });
});

// ── HTML handling: auto-trailing-slash ───────────────────────────────

describe("handleAssetRequest — auto-trailing-slash", () => {
  it("serves /about via /about.html", async () => {
    const { manifest, storage } = await makeAssets({
      "/about.html": "<h1>About</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/about"),
      manifest,
      storage,
      { html_handling: "auto-trailing-slash" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(await res!.text()).toBe("<h1>About</h1>");
  });

  it("serves /blog/ via /blog/index.html", async () => {
    const { manifest, storage } = await makeAssets({
      "/blog/index.html": "<h1>Blog</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/blog/"),
      manifest,
      storage,
      { html_handling: "auto-trailing-slash" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(await res!.text()).toBe("<h1>Blog</h1>");
  });

  it("serves / via /index.html", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/"),
      manifest,
      storage,
      { html_handling: "auto-trailing-slash" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(await res!.text()).toBe("<h1>Home</h1>");
  });

  it("serves exact binary files without HTML resolution", async () => {
    const { manifest, storage } = await makeAssets({
      "/logo.png": "PNG_DATA"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/logo.png"),
      manifest,
      storage,
      { html_handling: "auto-trailing-slash" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    const body = new TextDecoder().decode(await res!.arrayBuffer());
    expect(body).toBe("PNG_DATA");
  });
});

// ── HTML handling: none ─────────────────────────────────────────────

describe("handleAssetRequest — html_handling: none", () => {
  it("only serves exact matches", async () => {
    const { manifest, storage } = await makeAssets({
      "/about.html": "<h1>About</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/about"),
      manifest,
      storage,
      { html_handling: "none", not_found_handling: "none" }
    );
    expect(res).toBeNull();
  });

  it("serves exact .html path", async () => {
    const { manifest, storage } = await makeAssets({
      "/about.html": "<h1>About</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/about.html"),
      manifest,
      storage,
      { html_handling: "none" }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
  });
});

// ── Redirect tests ──────────────────────────────────────────────────

describe("handleAssetRequest — redirects", () => {
  it("handles static redirects", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/old"),
      manifest,
      storage,
      {
        redirects: {
          static: { "/old": { status: 301, to: "/new" } }
        }
      }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(301);
    expect(res!.headers.get("Location")).toBe("/new");
  });

  it("handles dynamic redirects with placeholders", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/blog/my-post"),
      manifest,
      storage,
      {
        redirects: {
          dynamic: { "/blog/:slug": { status: 302, to: "/posts/:slug" } }
        }
      }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(302);
    expect(res!.headers.get("Location")).toContain("/posts/my-post");
  });

  it("handles dynamic redirects with splat", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/old/path/to/page"),
      manifest,
      storage,
      {
        redirects: {
          dynamic: { "/old/*": { status: 301, to: "/new/*" } }
        }
      }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(301);
  });

  it("handles 200 proxy redirects (rewrite)", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>",
      "/users/id.html": "<h1>User Page</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/users/12345"),
      manifest,
      storage,
      {
        html_handling: "none",
        redirects: {
          static: { "/users/12345": { status: 200, to: "/users/id.html" } }
        }
      }
    );
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    expect(await res!.text()).toBe("<h1>User Page</h1>");
  });
});

// ── Custom headers tests ────────────────────────────────────────────

describe("handleAssetRequest — custom headers", () => {
  it("applies custom headers matching the path", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/index.html"),
      manifest,
      storage,
      {
        headers: {
          "/*": { set: { "X-Custom": "hello", "X-Frame-Options": "DENY" } }
        }
      }
    );
    expect(res).not.toBeNull();
    expect(res!.headers.get("X-Custom")).toBe("hello");
    expect(res!.headers.get("X-Frame-Options")).toBe("DENY");
  });

  it("applies path-specific headers", async () => {
    const { manifest, storage } = await makeAssets({
      "/api.html": "<h1>API</h1>",
      "/index.html": "<h1>Home</h1>"
    });

    const config: AssetConfig = {
      headers: { "/api*": { set: { "X-API": "true" } } }
    };

    const apiRes = await handleAssetRequest(
      new Request("http://example.com/api.html"),
      manifest,
      storage,
      config
    );
    expect(apiRes!.headers.get("X-API")).toBe("true");

    const homeRes = await handleAssetRequest(
      new Request("http://example.com/index.html"),
      manifest,
      storage,
      config
    );
    expect(homeRes!.headers.get("X-API")).toBeNull();
  });

  it("unsets headers", async () => {
    const { manifest, storage } = await makeAssets({
      "/index.html": "<h1>Home</h1>"
    });

    const res = await handleAssetRequest(
      new Request("http://example.com/index.html"),
      manifest,
      storage,
      { headers: { "/*": { unset: ["ETag"] } } }
    );
    expect(res).not.toBeNull();
    expect(res!.headers.get("ETag")).toBeNull();
  });
});

// ── normalizeConfig tests ───────────────────────────────────────────

describe("normalizeConfig", () => {
  it("returns defaults when no config provided", () => {
    const config = normalizeConfig();
    expect(config.html_handling).toBe("auto-trailing-slash");
    expect(config.not_found_handling).toBe("none");
    expect(config.redirects.static).toEqual({});
    expect(config.redirects.dynamic).toEqual({});
    expect(config.headers).toEqual({});
  });

  it("preserves user-provided values", () => {
    const config = normalizeConfig({
      html_handling: "none",
      not_found_handling: "single-page-application"
    });
    expect(config.html_handling).toBe("none");
    expect(config.not_found_handling).toBe("single-page-application");
  });

  it("assigns line numbers to static redirects", () => {
    const config = normalizeConfig({
      redirects: {
        static: {
          "/a": { status: 301, to: "/b" },
          "/c": { status: 302, to: "/d" }
        }
      }
    });
    expect(config.redirects.static["/a"].lineNumber).toBe(1);
    expect(config.redirects.static["/c"].lineNumber).toBe(2);
  });
});