From a0c30313c56e8f8514011a67ff2a98a148015b51 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Sun, 17 May 2026 14:45:01 -0400 Subject: [PATCH] test: add Vitest infrastructure, Codecov CI, and full unit test suite Mirrors the test setup from sister repo MPNext: Vitest + v8 coverage, jsdom env, shared test-setup with stubbed env vars, and a GitHub Actions workflow that runs on push/PR to main and uploads coverage-final.json to Codecov (token from secrets.CODECOV_TOKEN, fail_ci_if_error: false). Adapted the workflow for this repo's pnpm monorepo via pnpm/action-setup. Adds 511 tests across 30 files covering services, lib/embed auth/JWT/CORS, the Ministry Platform provider stack (helper, provider, client, http-client, client-credentials, table/procedure/metadata/domain/communication/file services), embed-SDK shared utilities (api-client, cdn-loader, base-widget), and all @mpnext/types Zod schemas. Coverage: 93.76% statements / 95.13% functions on the tested surface. Action item: add CODECOV_TOKEN to the repo's GitHub Actions secrets to enable coverage uploads from CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 37 + .gitignore | 1 + package.json | 11 +- .../embed-sdk/src/shared/api-client.test.ts | 312 ++++++ .../embed-sdk/src/shared/base-widget.test.ts | 345 +++++++ .../embed-sdk/src/shared/cdn-loader.test.ts | 163 +++ packages/types/src/add-to-calendar.test.ts | 149 +++ packages/types/src/full-calendar.test.ts | 252 +++++ packages/types/src/index.test.ts | 44 + packages/types/src/invoices.test.ts | 234 +++++ packages/types/src/profile.test.ts | 244 +++++ packages/types/src/subscription.test.ts | 271 +++++ pnpm-lock.yaml | 862 +++++++++++++++- src/lib/embed/auth.test.ts | 374 +++++++ src/lib/embed/config.test.ts | 95 ++ src/lib/embed/jwt.test.ts | 158 +++ src/lib/embed/recaptcha.test.ts | 107 ++ .../auth/client-credentials.test.ts | 185 ++++ .../ministry-platform/client.test.ts | 228 +++++ .../ministry-platform/helper.test.ts | 926 ++++++++++++++++++ .../ministry-platform/provider.test.ts | 329 +++++++ .../services/communication.service.test.ts | 184 ++++ .../services/domain.service.test.ts | 107 ++ .../services/file.service.test.ts | 338 +++++++ .../services/metadata.service.test.ts | 142 +++ .../services/procedure.service.test.ts | 221 +++++ .../services/table.service.test.ts | 350 +++++++ .../utils/http-client.test.ts | 449 +++++++++ src/services/addToCalendarService.test.ts | 164 ++++ src/services/fullCalendarService.test.ts | 302 ++++++ src/services/invoiceService.test.ts | 267 +++++ src/services/profileService.test.ts | 265 +++++ src/services/subscriptionService.test.ts | 250 +++++ src/services/userService.test.ts | 95 ++ src/test-setup.ts | 14 + vitest.config.ts | 42 + 36 files changed, 8514 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 packages/embed-sdk/src/shared/api-client.test.ts create mode 100644 packages/embed-sdk/src/shared/base-widget.test.ts create mode 100644 packages/embed-sdk/src/shared/cdn-loader.test.ts create mode 100644 packages/types/src/add-to-calendar.test.ts create mode 100644 packages/types/src/full-calendar.test.ts create mode 100644 packages/types/src/index.test.ts create mode 100644 packages/types/src/invoices.test.ts create mode 100644 packages/types/src/profile.test.ts create mode 100644 packages/types/src/subscription.test.ts create mode 100644 src/lib/embed/auth.test.ts create mode 100644 src/lib/embed/config.test.ts create mode 100644 src/lib/embed/jwt.test.ts create mode 100644 src/lib/embed/recaptcha.test.ts create mode 100644 src/lib/providers/ministry-platform/auth/client-credentials.test.ts create mode 100644 src/lib/providers/ministry-platform/client.test.ts create mode 100644 src/lib/providers/ministry-platform/helper.test.ts create mode 100644 src/lib/providers/ministry-platform/provider.test.ts create mode 100644 src/lib/providers/ministry-platform/services/communication.service.test.ts create mode 100644 src/lib/providers/ministry-platform/services/domain.service.test.ts create mode 100644 src/lib/providers/ministry-platform/services/file.service.test.ts create mode 100644 src/lib/providers/ministry-platform/services/metadata.service.test.ts create mode 100644 src/lib/providers/ministry-platform/services/procedure.service.test.ts create mode 100644 src/lib/providers/ministry-platform/services/table.service.test.ts create mode 100644 src/lib/providers/ministry-platform/utils/http-client.test.ts create mode 100644 src/services/addToCalendarService.test.ts create mode 100644 src/services/fullCalendarService.test.ts create mode 100644 src/services/invoiceService.test.ts create mode 100644 src/services/profileService.test.ts create mode 100644 src/services/subscriptionService.test.ts create mode 100644 src/services/userService.test.ts create mode 100644 src/test-setup.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..20d43ab --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests with coverage + run: pnpm test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage-final.json + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 02d0314..1337b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ playwright-report/ blob-report/ .playwright-mcp/ screenshots/ +coverage/ # Vercel .vercel diff --git a/package.json b/package.json index 07f3b8e..6458213 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "dev:demo": "pnpm build:sdk && next dev", "setup": "tsx scripts/setup.ts", "setup:check": "tsx scripts/setup.ts --check", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:widget": "playwright test --project=widget" }, @@ -58,19 +61,25 @@ "@inquirer/prompts": "^8.4.3", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^25.8.0", "@types/react": "^19.2.14", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.0", "autoprefixer": "^10.5.0", "chalk": "^5.6.2", "concurrently": "^9.2.1", "eslint": "^9", "eslint-config-next": "^16.2.6", + "jsdom": "^29.0.0", "playwright": "^1.60.0", "postcss": "^8.5.14", "tailwindcss": "^4.3.0", "tw-animate-css": "^1.3.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.0" }, "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc" } diff --git a/packages/embed-sdk/src/shared/api-client.test.ts b/packages/embed-sdk/src/shared/api-client.test.ts new file mode 100644 index 0000000..85c9bc2 --- /dev/null +++ b/packages/embed-sdk/src/shared/api-client.test.ts @@ -0,0 +1,312 @@ +/** + * Unit tests for ApiClient (packages/embed-sdk/src/shared/api-client.ts) + * + * Notes: + * - `fetch` is mocked on `globalThis` for each test. + * - The current `refreshToken()` implementation always returns `null`, so the + * public 401 retry path can only be exercised by stubbing the private method. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ApiClient, type ApiClientConfig } from "./api-client"; + +/** Build a Response-like object compatible with what ApiClient consumes. */ +function makeResponse( + body: unknown, + init: { status?: number; ok?: boolean; statusText?: string } = {}, +): Response { + const status = init.status ?? 200; + const ok = init.ok ?? (status >= 200 && status < 300); + return { + status, + ok, + statusText: init.statusText ?? "OK", + json: async () => body, + } as unknown as Response; +} + +describe("ApiClient", () => { + let fetchMock: ReturnType; + let getToken: ReturnType; + let onTokenRefresh: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.fn(); + getToken = vi.fn(async () => "test-token"); + onTokenRefresh = vi.fn(); + // Override the global fetch used inside ApiClient. + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const baseConfig = ( + overrides: Partial = {}, + ): ApiClientConfig => ({ + apiHost: "https://api.example.com", + getToken, + onTokenRefresh, + ...overrides, + }); + + describe("constructor", () => { + it("accepts an apiHost and token provider", () => { + const client = new ApiClient(baseConfig()); + expect(client).toBeInstanceOf(ApiClient); + }); + + it("works without optional onTokenRefresh", () => { + const client = new ApiClient({ + apiHost: "https://api.example.com", + getToken, + }); + expect(client).toBeInstanceOf(ApiClient); + }); + }); + + describe("request()", () => { + it("sets Authorization: Bearer header from getToken()", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({ ok: true })); + const client = new ApiClient(baseConfig()); + + await client.request("/widgets"); + + expect(getToken).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.example.com/widgets"); + const headers = init.headers as Record; + expect(headers.Authorization).toBe("Bearer test-token"); + expect(headers["Content-Type"]).toBe("application/json"); + }); + + it("includes X-Tenant-ID header when tenantId is configured", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({})); + const client = new ApiClient(baseConfig({ tenantId: "northwoods-dev" })); + + await client.request("/data"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Tenant-ID"]).toBe("northwoods-dev"); + }); + + it("omits X-Tenant-ID when tenantId is not configured", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({})); + const client = new ApiClient(baseConfig()); + + await client.request("/data"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Tenant-ID"]).toBeUndefined(); + }); + + it("merges caller-supplied headers and forwards Idempotency-Key", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({})); + const client = new ApiClient(baseConfig()); + + await client.request("/x", { + headers: { "Idempotency-Key": "abc-123", "X-Custom": "y" }, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Idempotency-Key"]).toBe("abc-123"); + expect(headers["X-Custom"]).toBe("y"); + // Auth still applied + expect(headers.Authorization).toBe("Bearer test-token"); + }); + + it("uses credentials: omit and mode: cors", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({})); + const client = new ApiClient(baseConfig()); + + await client.request("/x"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.credentials).toBe("omit"); + expect(init.mode).toBe("cors"); + }); + + it("returns parsed JSON body on success", async () => { + const payload = { id: 7, name: "calendar" }; + fetchMock.mockResolvedValueOnce(makeResponse(payload)); + const client = new ApiClient(baseConfig()); + + const result = await client.request("/widget/7"); + + expect(result).toEqual(payload); + }); + + it("throws with server error message on non-2xx response", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse({ error: "Bad widget" }, { status: 400, ok: false, statusText: "Bad Request" }), + ); + const client = new ApiClient(baseConfig()); + + await expect(client.request("/x")).rejects.toThrow("Bad widget"); + }); + + it("falls back to statusText when the error body has no error field", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse({}, { status: 500, ok: false, statusText: "Internal Server Error" }), + ); + const client = new ApiClient(baseConfig()); + + await expect(client.request("/x")).rejects.toThrow(/Internal Server Error/); + }); + + it("falls back to statusText when the error body is unparsable JSON", async () => { + const badResponse = { + status: 502, + ok: false, + statusText: "Bad Gateway", + json: async () => { + throw new Error("not json"); + }, + } as unknown as Response; + fetchMock.mockResolvedValueOnce(badResponse); + const client = new ApiClient(baseConfig()); + + await expect(client.request("/x")).rejects.toThrow(/Bad Gateway/); + }); + + it("retries exactly once after a 401 and surfaces the second response", async () => { + // First call returns 401, second call returns 200. + fetchMock + .mockResolvedValueOnce( + makeResponse({}, { status: 401, ok: false, statusText: "Unauthorized" }), + ) + .mockResolvedValueOnce(makeResponse({ ok: true })); + + const client = new ApiClient(baseConfig()); + // Force refreshToken() to return a non-null value so the retry branch runs. + const refreshSpy = vi + .spyOn(client as unknown as { refreshToken: () => Promise }, "refreshToken") + .mockResolvedValue("new-token"); + + const result = await client.request<{ ok: boolean }>("/x"); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).toEqual({ ok: true }); + }); + + it("does not loop infinitely if 401 persists after retry", async () => { + fetchMock.mockResolvedValue( + makeResponse({ error: "still unauth" }, { + status: 401, + ok: false, + statusText: "Unauthorized", + }), + ); + + const client = new ApiClient(baseConfig()); + vi.spyOn(client as unknown as { refreshToken: () => Promise }, "refreshToken") + .mockResolvedValue("another-token"); + + await expect(client.request("/x")).rejects.toThrow(); + // First call + exactly one retry; nothing more. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry on 401 when onTokenRefresh is not configured", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse({ error: "nope" }, { status: 401, ok: false, statusText: "Unauthorized" }), + ); + + const client = new ApiClient({ + apiHost: "https://api.example.com", + getToken, + // no onTokenRefresh + }); + + await expect(client.request("/x")).rejects.toThrow("nope"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 401 when skipRetry is set by the caller", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse({ error: "nope" }, { status: 401, ok: false, statusText: "Unauthorized" }), + ); + + const client = new ApiClient(baseConfig()); + const refreshSpy = vi + .spyOn(client as unknown as { refreshToken: () => Promise }, "refreshToken") + .mockResolvedValue("new-token"); + + await expect( + client.request("/x", { skipRetry: true } as RequestInit & { skipRetry: boolean }), + ).rejects.toThrow("nope"); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry on non-401 errors", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse({ error: "boom" }, { status: 500, ok: false, statusText: "Server Error" }), + ); + + const client = new ApiClient(baseConfig()); + const refreshSpy = vi + .spyOn(client as unknown as { refreshToken: () => Promise }, "refreshToken") + .mockResolvedValue("new-token"); + + await expect(client.request("/x")).rejects.toThrow("boom"); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("post()", () => { + it("serializes body as JSON and sets method POST", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({ created: true })); + const client = new ApiClient(baseConfig()); + + const result = await client.post<{ created: boolean }>("/things", { + name: "Pickle", + count: 3, + }); + + expect(result).toEqual({ created: true }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.example.com/things"); + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ name: "Pickle", count: 3 })); + const headers = init.headers as Record; + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers.Authorization).toBe("Bearer test-token"); + }); + + it("forwards additional init like Idempotency-Key header through post()", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({})); + const client = new ApiClient(baseConfig()); + + await client.post("/things", { a: 1 }, { + headers: { "Idempotency-Key": "key-xyz" }, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Idempotency-Key"]).toBe("key-xyz"); + }); + }); + + describe("get()", () => { + it("sets method GET and applies Authorization header", async () => { + fetchMock.mockResolvedValueOnce(makeResponse({ x: 1 })); + const client = new ApiClient(baseConfig()); + + const result = await client.get<{ x: number }>("/things"); + + expect(result).toEqual({ x: 1 }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("GET"); + const headers = init.headers as Record; + expect(headers.Authorization).toBe("Bearer test-token"); + }); + }); +}); diff --git a/packages/embed-sdk/src/shared/base-widget.test.ts b/packages/embed-sdk/src/shared/base-widget.test.ts new file mode 100644 index 0000000..a7bd54e --- /dev/null +++ b/packages/embed-sdk/src/shared/base-widget.test.ts @@ -0,0 +1,345 @@ +/** + * Unit tests for MPNextWidget (packages/embed-sdk/src/shared/base-widget.ts) + * + * Strategy: + * - jsdom supports HTMLElement + Shadow DOM, so we register a concrete test + * subclass via `customElements.define`. To avoid double-registration when + * the module is reset across describe blocks, we use a unique tag name + * per test file. + * - We expose the protected `fetch()` helper through a public method on the + * subclass so tests can drive it. + * - `window.__nextTokenProvider` / `window.__nextSDKReady` are reset in + * beforeEach to keep tests isolated. + * - `globalThis.fetch` is mocked per test. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { MPNextWidget } from "./base-widget"; + +// Concrete test subclass with public hooks into the protected API. +class TestWidget extends MPNextWidget { + public renderCalled = 0; + public connectedCalled = 0; + render(): void { + this.renderCalled += 1; + } + connectedCallback(): void { + this.connectedCalled += 1; + } + // Expose protected helpers for testing. + public callFetch(path: string, init?: RequestInit): Promise { + return (this as unknown as { fetch: MPNextWidget["fetch"] }).fetch(path, init); + } + public callInjectStyles(css: string): void { + (this as unknown as { injectStyles: MPNextWidget["injectStyles"] }).injectStyles(css); + } + public callEmit(name: string, detail?: unknown): void { + (this as unknown as { emit: MPNextWidget["emit"] }).emit(name, detail); + } + public getRoot(): ShadowRoot { + return (this as unknown as { root: ShadowRoot }).root; + } + public getApiHost(): string { + return (this as unknown as { apiHost: string }).apiHost; + } +} + +const TAG = "test-mpnext-widget"; +if (!customElements.get(TAG)) { + customElements.define(TAG, TestWidget); +} + +function makeWidget(): TestWidget { + return document.createElement(TAG) as TestWidget; +} + +function makeResponse( + body: unknown, + init: { status?: number; ok?: boolean } = {}, +): Response { + const status = init.status ?? 200; + const ok = init.ok ?? (status >= 200 && status < 300); + return { + status, + ok, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} + +describe("MPNextWidget", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + // Reset SDK globals that base-widget reads. + delete (window as unknown as { __nextTokenProvider?: unknown }).__nextTokenProvider; + delete (window as unknown as { __nextSDKReady?: unknown }).__nextSDKReady; + delete (window as unknown as { __nextEmbedApiHost?: unknown }).__nextEmbedApiHost; + + // Clean any leftover SDK script tags inserted by previous tests. + document.querySelectorAll('script[src*="next-embed"]').forEach((el) => el.remove()); + document.body.innerHTML = ""; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("construction", () => { + it("attaches an open Shadow DOM root", () => { + const widget = makeWidget(); + expect(widget.shadowRoot).not.toBeNull(); + expect(widget.getRoot()).toBe(widget.shadowRoot); + }); + + it("reads api-host from the element attribute when provided", () => { + const widget = document.createElement(TAG) as TestWidget; + widget.setAttribute("api-host", "https://api.from-attr.com"); + // Re-construct so the attribute is read in the constructor path. + const widget2 = document.createElement(TAG) as TestWidget; + widget2.setAttribute("api-host", "https://api.from-attr.com"); + // jsdom calls the constructor when createElement runs. setAttribute after + // construction won't change the captured apiHost, so we instead use a + // fresh element where the attribute is set BEFORE construction is + // exercised via upgrade. Use innerHTML to ensure attributes parse first. + document.body.innerHTML = `<${TAG} api-host="https://api.from-attr.com">`; + const upgraded = document.body.firstElementChild as TestWidget; + expect(upgraded.getApiHost()).toBe("https://api.from-attr.com"); + + // Silence unused-var lint on the two earlier-built widgets. + void widget; + void widget2; + }); + + it("falls back to window.__nextEmbedApiHost when no attribute is set", () => { + (window as unknown as { __nextEmbedApiHost?: string }).__nextEmbedApiHost = + "https://api.from-global.com"; + const widget = makeWidget(); + expect(widget.getApiHost()).toBe("https://api.from-global.com"); + }); + + it("falls back to the SDK script-tag origin when no other hint is present", () => { + const script = document.createElement("script"); + script.src = "https://cdn.example.com/sdk/next-embed.es.js"; + document.body.appendChild(script); + + const widget = makeWidget(); + expect(widget.getApiHost()).toBe("https://cdn.example.com"); + }); + + it("returns empty apiHost when nothing is configured", () => { + const widget = makeWidget(); + expect(widget.getApiHost()).toBe(""); + }); + }); + + describe("token-provider acquisition", () => { + it("does not require a token provider at construction time", () => { + // No __nextTokenProvider on window — construction must not throw. + expect(() => makeWidget()).not.toThrow(); + }); + + it("uses the token provider once when fetching with a valid token", async () => { + const get = vi.fn(async () => "abc123"); + (window as unknown as { + __nextTokenProvider: { get: () => Promise }; + }).__nextTokenProvider = { get }; + + fetchMock.mockResolvedValueOnce(makeResponse({ ok: true })); + + const widget = makeWidget(); + widget.setAttribute("api-host", "https://api.example.com"); + // Need the apiHost captured at construction time; rebuild via innerHTML. + document.body.innerHTML = `<${TAG} api-host="https://api.example.com">`; + const upgraded = document.body.firstElementChild as TestWidget; + + const res = await upgraded.callFetch("/me"); + expect(res.ok).toBe(true); + expect(get).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.example.com/me"); + expect((init.headers as Record).Authorization).toBe( + "Bearer abc123", + ); + expect(init.credentials).toBe("omit"); + expect(init.mode).toBe("cors"); + }); + + it("awaits window.__nextSDKReady before reading the token provider", async () => { + let resolveReady!: () => void; + const ready = new Promise((r) => { + resolveReady = r; + }); + (window as unknown as { __nextSDKReady: Promise }).__nextSDKReady = ready; + + // Token provider is not present yet. + const widget = makeWidget(); + const fetchPromise = widget.callFetch("/x"); + + // Should not have called fetch yet because the SDK is "not ready" and + // there's no token provider. + expect(fetchMock).not.toHaveBeenCalled(); + + // Provide the token provider, then resolve the ready promise. + const get = vi.fn(async () => "late-token"); + (window as unknown as { + __nextTokenProvider: { get: () => Promise }; + }).__nextTokenProvider = { get }; + resolveReady(); + + fetchMock.mockResolvedValueOnce(makeResponse({ ok: true })); + await fetchPromise; + + expect(get).toHaveBeenCalledTimes(1); + }); + + it("throws when the token resolves to empty", async () => { + (window as unknown as { + __nextTokenProvider: { get: () => Promise }; + }).__nextTokenProvider = { get: async () => "" }; + + const widget = makeWidget(); + await expect(widget.callFetch("/x")).rejects.toThrow( + "Authentication token not available.", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("fetch() with auto-refresh on 401", () => { + it("calls refresh() and retries the request with the new token on 401", async () => { + const get = vi.fn(async () => "stale-token"); + const refresh = vi.fn(async () => "fresh-token"); + (window as unknown as { + __nextTokenProvider: { + get: () => Promise; + refresh: () => Promise; + }; + }).__nextTokenProvider = { get, refresh }; + + // First call: 401. Second call (after refresh): 200. + fetchMock + .mockResolvedValueOnce(makeResponse({}, { status: 401, ok: false })) + .mockResolvedValueOnce(makeResponse({ ok: true }, { status: 200 })); + + document.body.innerHTML = `<${TAG} api-host="https://api.example.com">`; + const widget = document.body.firstElementChild as TestWidget; + + const res = await widget.callFetch("/secure"); + expect(res.status).toBe(200); + expect(refresh).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Second fetch must use the refreshed token. + const [, secondInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + const headers = secondInit.headers as Record; + expect(headers.Authorization).toBe("Bearer fresh-token"); + }); + + it("does not retry on 401 when no refresh() function is provided", async () => { + const get = vi.fn(async () => "stale-token"); + (window as unknown as { + __nextTokenProvider: { get: () => Promise }; + }).__nextTokenProvider = { get }; + + fetchMock.mockResolvedValueOnce(makeResponse({}, { status: 401, ok: false })); + + const widget = makeWidget(); + const res = await widget.callFetch("/secure"); + expect(res.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry on non-401 errors", async () => { + const get = vi.fn(async () => "tok"); + const refresh = vi.fn(async () => "new-tok"); + (window as unknown as { + __nextTokenProvider: { + get: () => Promise; + refresh: () => Promise; + }; + }).__nextTokenProvider = { get, refresh }; + + fetchMock.mockResolvedValueOnce(makeResponse({ err: "no" }, { status: 500, ok: false })); + + const widget = makeWidget(); + const res = await widget.callFetch("/oops"); + expect(res.status).toBe(500); + expect(refresh).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("injectStyles()", () => { + it("uses adoptedStyleSheets when available", () => { + const widget = makeWidget(); + widget.callInjectStyles(":host { color: red; }"); + + // In jsdom (modern enough), adoptedStyleSheets is supported. + const sheets = widget.getRoot().adoptedStyleSheets; + if (sheets && sheets.length > 0) { + expect(sheets.length).toBe(1); + expect(sheets[0]).toBeInstanceOf(CSSStyleSheet); + } else { + // Fallback path: a