/**
* tests/unit/indexer-server-spa-catchall.test.ts — RED phase (M1-F-5)
*
* Category G: Express SPA catch-all extension (~8 tests)
* supertest-driven.
*
* All tests marked "RED" FAIL until M1-F-5-green because createApp does not yet
* accept uiDistDir and does not mount static files or the SPA catch-all.
*
* Design contract for Catherine:
* - createApp(deps, options) gains an optional third-ish arg: options.uiDistDir
* - When uiDistDir is provided:
* (a) express.static(uiDistDir) is added AFTER all JSON routes
* (b) app.get('*', (req, res) => res.sendFile('index.html', {root: uiDistDir}))
* is added as the SPA catch-all LAST
* - When uiDistDir is NOT provided (default, existing unit tests), behavior is unchanged:
* unknown paths return 404 (Express default)
* - JSON endpoints at /healthz, /suppliers, /capabilities, /escrows, /events
* still return JSON 200 / correct status (priority preserved)
*
* Test setup:
* - Creates a real temp directory with a minimal index.html so sendFile works
* - Uses supertest against the returned Application (no actual listening port)
*
* NOTE: The existing "unknown routes" tests in indexer-routes.test.ts still pass
* because they use createApp(deps) without uiDistDir — backwards-compatible.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { mkdtempSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { createApp } from "../../indexer/src/server.js";
import type { IndexerDeps } from "../../indexer/src/server.js";
import type { AdvertRow } from "../../indexer/src/db/cache.js";
import {
INDEXER_SUPPLIER_PKH,
INDEXER_SUPPLIER_ENDPOINT,
} from "../fixtures/indexer-side/sample-blocks.js";
// ─── Temp dist directory ─────────────────────────────────────────────────────
// Created once for all SPA tests; holds a minimal index.html and a fake JS file.
let distDir: string;
beforeAll(() => {
distDir = mkdtempSync(join(tmpdir(), "indexer-ui-dist-"));
writeFileSync(join(distDir, "index.html"), "
IndexerUI");
writeFileSync(join(distDir, "app.js"), "// fake bundle");
});
afterAll(() => {
rmSync(distDir, { recursive: true, force: true });
});
// ─── Mock deps ────────────────────────────────────────────────────────────────
function sampleAdvert(overrides: Partial = {}): AdvertRow {
return {
utxo_ref: "a".repeat(64) + "#0",
supplier_pkh: INDEXER_SUPPLIER_PKH,
capability_id: "llm.text.generate.v1",
model: "qwen2.5:0.5b",
max_output_tokens: 512,
max_processing_ms: 60_000,
price_lovelace: "2000000",
supplier_bond_lovelace: "1000000",
buyer_bond_lovelace: "1000000",
endpoint_url: INDEXER_SUPPLIER_ENDPOINT,
detail_uri: "ipfs://QmTest",
detail_hash: "b".repeat(64),
advertised_at: 1_745_500_000,
status: "Active",
created_slot: 1_000_000,
datum_hex: "",
rolled_back: 0,
...overrides,
} as AdvertRow;
}
function makeMockDeps(): IndexerDeps {
const cache = {
listActiveAdvertisements: vi.fn(() => [sampleAdvert()]),
getAdvertisementByRef: vi.fn(() => null),
getEscrowByRef: vi.fn(() => null),
listEscrowsByBuyer: vi.fn(() => []),
listEscrowsBySupplier: vi.fn(() => []),
getSupplierStatus: vi.fn(() => ({
supplier_pkh: INDEXER_SUPPLIER_PKH,
advert_ref: "a".repeat(64) + "#0",
status: "free",
last_seen_iso: "2026-04-24T10:00:00.000Z",
current_escrow_ref: null,
polled_at: Date.now(),
})),
listEventsAfterSlot: vi.fn(() => []),
dbSizeBytes: vi.fn(() => 4096),
} as unknown as IndexerDeps["cache"];
const worker = {
getCurrentSlot: vi.fn(() => 1_000_000),
getTipSlot: vi.fn(() => 1_001_000),
on: vi.fn(),
off: vi.fn(),
} as unknown as IndexerDeps["worker"];
return { cache, worker };
}
// ─── JSON route priority preserved ───────────────────────────────────────────
describe("SPA catch-all — JSON route priority preserved", () => {
it("RED: GET /healthz still returns JSON 200 when uiDistDir is set", async () => {
// RED: createApp does not yet accept uiDistDir option — fails until M1-F-5-green
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get("/healthz");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/json/);
});
it("RED: GET /suppliers still returns JSON when uiDistDir is set", async () => {
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get("/suppliers");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/json/);
});
it("RED: GET /capabilities still returns JSON when uiDistDir is set", async () => {
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get("/capabilities");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/json/);
});
it("RED: GET /escrows?buyer= still returns JSON when uiDistDir is set", async () => {
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get(`/escrows?buyer=${INDEXER_SUPPLIER_PKH}`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/json/);
});
it("RED: GET /events?stream=1 still returns text/event-stream when uiDistDir is set", async () => {
// Test the SSE route using the Node http module directly to get headers
// without waiting for the body (supertest awaits the full response which
// never arrives for a long-lived SSE stream, so we use http.get instead).
const http = await import("http");
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const contentType = await new Promise((resolve, reject) => {
// Start a server on a random port, make one request, grab the header, destroy.
const server = http.createServer(app).listen(0, () => {
const { port } = server.address() as { port: number };
const req = http.get(`http://127.0.0.1:${port}/events?stream=1`, (res) => {
resolve(res.headers["content-type"] ?? "");
req.destroy();
server.close();
});
req.on("error", (err) => {
// Ignore "socket hang up" that results from req.destroy()
if ((err as NodeJS.ErrnoException).code === "ECONNRESET") return;
reject(err);
});
});
server.on("error", reject);
});
expect(contentType).toMatch(/event-stream/);
}, 10_000);
});
// ─── SPA catch-all behaviour ─────────────────────────────────────────────────
describe("SPA catch-all — index.html served", () => {
it("RED: GET / returns text/html (index.html) when uiDistDir is set", async () => {
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get("/");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/html/);
expect(res.text).toContain("IndexerUI");
});
it("RED: GET /some-spa-route returns the same index.html (SPA catch-all)", async () => {
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get("/some-spa-route");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/html/);
expect(res.text).toContain("IndexerUI");
});
it("RED: GET /app.js returns the static JS file from dist", async () => {
const app = createApp(makeMockDeps(), { uiDistDir: distDir } as never);
const res = await request(app).get("/app.js");
expect(res.status).toBe(200);
expect(res.text).toContain("fake bundle");
});
});