diff --git a/src/api/subscriptionsApi.test.ts b/src/api/subscriptionsApi.test.ts new file mode 100644 index 0000000..686b635 --- /dev/null +++ b/src/api/subscriptionsApi.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + listActive, + subscribe, + unsubscribe, + SubscriptionsApiError, + isSubscriptionsAbortError, + normalizeSubscriptionStatus, + adaptBackendSubscription, + validateSubscriptionsListResponse, + validateSubscriptionResponse, + isSubscriptionsDemoMode, + SUBSCRIPTIONS_PATH, +} from "./subscriptionsApi"; +import { addAccount, switchAccount, _reset } from "../state/accountStore"; +import * as constants from "../config/constants"; + +describe("subscriptionsApi", () => { + beforeEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + _reset(); + }); + + afterEach(() => { + localStorage.clear(); + _reset(); + }); + + describe("normalizeSubscriptionStatus", () => { + it("normalizes known statuses", () => { + expect(normalizeSubscriptionStatus("active")).toBe("active"); + expect(normalizeSubscriptionStatus("PAUSED")).toBe("paused"); + expect(normalizeSubscriptionStatus(" cancelled ")).toBe("cancelled"); + }); + + it("falls back to unknown for non-strings or invalid strings", () => { + expect(normalizeSubscriptionStatus(null)).toBe("unknown"); + expect(normalizeSubscriptionStatus(undefined)).toBe("unknown"); + expect(normalizeSubscriptionStatus(123)).toBe("unknown"); + expect(normalizeSubscriptionStatus("deleted")).toBe("unknown"); + }); + }); + + describe("adaptBackendSubscription", () => { + it("throws if input is not an object", () => { + expect(() => adaptBackendSubscription(null)).toThrow(SubscriptionsApiError); + expect(() => adaptBackendSubscription("str")).toThrow(SubscriptionsApiError); + }); + + it("throws if id or api_id is missing", () => { + expect(() => adaptBackendSubscription({ api_id: "api-1" })).toThrow("missing an id"); + expect(() => adaptBackendSubscription({ id: "sub-1" })).toThrow("missing api_id"); + }); + + it("adapts valid backend subscription with snake_case and camelCase", () => { + const sub1 = adaptBackendSubscription({ + id: "sub-123", + api_id: "api-weather", + status: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }); + expect(sub1.id).toBe("sub-123"); + expect(sub1.apiId).toBe("api-weather"); + expect(sub1.status).toBe("active"); + expect(sub1.rawStatus).toBe("active"); + expect(sub1.createdAt).toBe("2026-01-01T00:00:00Z"); + + const sub2 = adaptBackendSubscription({ + id: "sub-456", + apiId: "api-finance", + status: "PAUSED", + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-02T00:00:00Z", + }); + expect(sub2.id).toBe("sub-456"); + expect(sub2.apiId).toBe("api-finance"); + expect(sub2.status).toBe("paused"); + }); + }); + + describe("validateSubscriptionsListResponse & validateSubscriptionResponse", () => { + it("parses raw arrays", () => { + const res = validateSubscriptionsListResponse([ + { id: "sub-1", api_id: "api-1", status: "active" }, + ]); + expect(res).toHaveLength(1); + expect(res[0].id).toBe("sub-1"); + }); + + it("parses { data: [...] } and { subscriptions: [...] }", () => { + const res1 = validateSubscriptionsListResponse({ + data: [{ id: "sub-1", api_id: "api-1", status: "active" }], + }); + expect(res1).toHaveLength(1); + + const res2 = validateSubscriptionsListResponse({ + subscriptions: [{ id: "sub-2", api_id: "api-2", status: "active" }], + }); + expect(res2).toHaveLength(1); + }); + + it("throws SubscriptionsApiError if list shape is invalid", () => { + expect(() => validateSubscriptionsListResponse({ invalid: true })).toThrow( + SubscriptionsApiError, + ); + }); + + it("parses single subscription response envelope or raw", () => { + const s1 = validateSubscriptionResponse({ id: "sub-1", api_id: "api-1", status: "active" }); + expect(s1.id).toBe("sub-1"); + + const s2 = validateSubscriptionResponse({ + data: { id: "sub-2", api_id: "api-2", status: "active" }, + }); + expect(s2.id).toBe("sub-2"); + + const s3 = validateSubscriptionResponse({ + subscription: { id: "sub-3", api_id: "api-3", status: "active" }, + }); + expect(s3.id).toBe("sub-3"); + }); + }); + + describe("listActive", () => { + it("fetches active subscriptions and attaches authorization header", async () => { + addAccount({ id: "acc-1", label: "Test Acc", apiKey: "secret-key-123" }); + switchAccount("acc-1"); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => [{ id: "sub-1", api_id: "api-weather", status: "active" }], + } as unknown as Response); + + const result = await listActive({ fetchImpl: mockFetch }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain(`${SUBSCRIPTIONS_PATH}?status=active`); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBe("Bearer secret-key-123"); + expect(headers.get("Accept")).toBe("application/json"); + expect(result).toHaveLength(1); + expect(result[0].apiId).toBe("api-weather"); + }); + + it("throws UNAUTHORIZED on 401", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + } as unknown as Response); + + await expect(listActive({ fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + status: 401, + }); + }); + + it("throws NETWORK_ERROR when fetch fails", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("Failed to fetch")); + + await expect(listActive({ fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "NETWORK_ERROR", + }); + }); + + it("throws HTTP_ERROR on 500", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + } as unknown as Response); + + await expect(listActive({ fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "HTTP_ERROR", + status: 500, + }); + }); + + it("handles abort signals cleanly", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(listActive({ signal: controller.signal })).rejects.toThrow( + "The operation was aborted.", + ); + }); + + it("handles unreadable response JSON", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new Error("Invalid json"); + }, + } as unknown as Response); + + await expect(listActive({ fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + }); + }); + }); + + describe("subscribe", () => { + it("posts api_id and includes Idempotency-Key", async () => { + addAccount({ id: "acc-1", label: "Test Acc", apiKey: "api-tok" }); + switchAccount("acc-1"); + + let receivedKey = ""; + const mockFetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => { + const headers = new Headers(init.headers); + receivedKey = headers.get("Idempotency-Key") || ""; + return { + ok: true, + status: 201, + json: async () => ({ + id: "sub-999", + api_id: "api-geo", + status: "active", + }), + } as Response; + }); + + const res = await subscribe("api-geo", { fetchImpl: mockFetch }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain(SUBSCRIPTIONS_PATH); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ api_id: "api-geo" }); + expect(receivedKey).toBeTruthy(); + expect(receivedKey.length).toBeGreaterThan(10); + expect(res.id).toBe("sub-999"); + }); + + it("uses provided idempotencyKey if specified", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: "sub-999", api_id: "api-geo", status: "active" }), + } as unknown as Response); + + await subscribe("api-geo", { + idempotencyKey: "custom-idempotency-key", + fetchImpl: mockFetch, + }); + + const [, init] = mockFetch.mock.calls[0]; + const headers = new Headers(init.headers); + expect(headers.get("Idempotency-Key")).toBe("custom-idempotency-key"); + }); + + it("throws INVALID_RESPONSE when apiId is empty", async () => { + await expect(subscribe("")).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + }); + }); + + it("throws CONFLICT on 409", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ message: "Already subscribed" }), + } as unknown as Response); + + await expect(subscribe("api-geo", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "CONFLICT", + status: 409, + }); + }); + + it("throws NOT_FOUND on 404", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: "API not found" }), + } as unknown as Response); + + await expect(subscribe("api-geo", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "NOT_FOUND", + status: 404, + }); + }); + + it("throws UNAUTHORIZED on 401", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + } as unknown as Response); + + await expect(subscribe("api-geo", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + status: 401, + }); + }); + + it("throws HTTP_ERROR on 500", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: "Internal server error" }), + } as unknown as Response); + + await expect(subscribe("api-geo", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "HTTP_ERROR", + status: 500, + }); + }); + }); + + describe("unsubscribe", () => { + it("calls DELETE /api/subscriptions/:id", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true }), + } as unknown as Response); + + await unsubscribe("sub-123", { fetchImpl: mockFetch }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain(`${SUBSCRIPTIONS_PATH}/sub-123`); + expect(init.method).toBe("DELETE"); + }); + + it("throws INVALID_RESPONSE when subscriptionId is empty", async () => { + await expect(unsubscribe("")).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + }); + }); + + it("throws UNAUTHORIZED on 401", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + } as unknown as Response); + + await expect(unsubscribe("sub-123", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + status: 401, + }); + }); + + it("throws NOT_FOUND on 404", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + } as unknown as Response); + + await expect(unsubscribe("sub-123", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "NOT_FOUND", + status: 404, + }); + }); + + it("throws HTTP_ERROR on 500", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: "Internal server error" }), + } as unknown as Response); + + await expect(unsubscribe("sub-123", { fetchImpl: mockFetch })).rejects.toMatchObject({ + code: "HTTP_ERROR", + status: 500, + }); + }); + + it("parses error string property if message is absent", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: "Custom error" }), + } as unknown as Response); + + await expect(unsubscribe("sub-123", { fetchImpl: mockFetch })).rejects.toThrow("Custom error"); + }); + + it("handles json parse failure on error response", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => { + throw new Error("bad json"); + }, + } as unknown as Response); + + await expect(unsubscribe("sub-123", { fetchImpl: mockFetch })).rejects.toThrow("Could not unsubscribe"); + }); + }); + + describe("demo mode & helper predicates", () => { + it("reports demo mode based on API_BASE", () => { + expect(typeof isSubscriptionsDemoMode()).toBe("boolean"); + }); + + it("identifies AbortError", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + expect(isSubscriptionsAbortError(err)).toBe(true); + expect(isSubscriptionsAbortError(new Error("regular error"))).toBe(false); + expect(isSubscriptionsAbortError(null)).toBe(false); + }); + }); +}); diff --git a/src/api/subscriptionsApi.ts b/src/api/subscriptionsApi.ts new file mode 100644 index 0000000..b9ad5ba --- /dev/null +++ b/src/api/subscriptionsApi.ts @@ -0,0 +1,508 @@ +/** + * subscriptionsApi.ts + * + * Client module for managing API subscriptions via Callora-Backend + * (`/api/subscriptions`). + * + * Endpoints: + * - `GET /api/subscriptions?status=active`: Lists subscriptions for current user. + * - `POST /api/subscriptions`: Creates a subscription ({ api_id: string }). + * Carries an `Idempotency-Key` header generated via `generateIdempotencyKey`. + * - `DELETE /api/subscriptions/:id`: Cancels / deletes a subscription. + * + * All endpoints require authentication. + */ + +import { API_BASE, apiUrl } from "../config/constants"; +import { getCurrentAccount } from "../state/accountStore"; +import { generateIdempotencyKey } from "../services/idempotency"; + +/** Path the subscriptions endpoint is mounted at. */ +export const SUBSCRIPTIONS_PATH = "/api/subscriptions"; + +export type SubscriptionStatus = "active" | "paused" | "cancelled" | "unknown"; + +export interface SubscriptionItem { + id: string; + apiId: string; + status: SubscriptionStatus; + rawStatus?: string; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +} + +export interface ListSubscriptionsOptions { + /** Optional status filter (default: "active") */ + status?: string; + /** AbortSignal to cancel the in-flight request */ + signal?: AbortSignal; + /** Base URL override (defaults to API_BASE). */ + baseUrl?: string; + /** Injectable fetch implementation for tests */ + fetchImpl?: typeof fetch; +} + +export interface SubscribeOptions { + /** Optional custom idempotency key; auto-generated if not provided */ + idempotencyKey?: string; + /** AbortSignal to cancel in-flight request */ + signal?: AbortSignal; + /** Base URL override (defaults to API_BASE). */ + baseUrl?: string; + /** Injectable fetch implementation for tests */ + fetchImpl?: typeof fetch; +} + +export interface UnsubscribeOptions { + /** AbortSignal to cancel in-flight request */ + signal?: AbortSignal; + /** Base URL override (defaults to API_BASE). */ + baseUrl?: string; + /** Injectable fetch implementation for tests */ + fetchImpl?: typeof fetch; +} + +export class SubscriptionsApiError extends Error { + public readonly status: number | null; + public readonly code: + | "UNAUTHORIZED" + | "NOT_FOUND" + | "CONFLICT" + | "HTTP_ERROR" + | "NETWORK_ERROR" + | "INVALID_RESPONSE"; + + constructor( + message: string, + status: number | null = null, + code: SubscriptionsApiError["code"] = "HTTP_ERROR", + ) { + super(message); + name: "SubscriptionsApiError"; + this.name = "SubscriptionsApiError"; + this.status = status; + this.code = code; + } +} + +export function isSubscriptionsAbortError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { name?: unknown }).name === "AbortError" + ); +} + +function abortError(): Error { + const err = new Error("The operation was aborted."); + err.name = "AbortError"; + return err; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function normalizeSubscriptionStatus(status: unknown): SubscriptionStatus { + if (typeof status !== "string") return "unknown"; + const s = status.trim().toLowerCase(); + if (s === "active" || s === "paused" || s === "cancelled") { + return s as SubscriptionStatus; + } + return "unknown"; +} + +export function adaptBackendSubscription(row: unknown): SubscriptionItem { + if (!isRecord(row)) { + throw new SubscriptionsApiError( + "Subscription entry is not an object.", + null, + "INVALID_RESPONSE", + ); + } + + const id = row.id !== undefined && row.id !== null ? String(row.id).trim() : ""; + if (!id) { + throw new SubscriptionsApiError( + "Subscription entry is missing an id.", + null, + "INVALID_RESPONSE", + ); + } + + const rawApiId = row.api_id ?? row.apiId; + const apiId = rawApiId !== undefined && rawApiId !== null ? String(rawApiId).trim() : ""; + if (!apiId) { + throw new SubscriptionsApiError( + "Subscription entry is missing api_id.", + null, + "INVALID_RESPONSE", + ); + } + + const status = normalizeSubscriptionStatus(row.status); + const rawStatus = typeof row.status === "string" ? row.status.trim() : undefined; + const createdAt = + typeof row.created_at === "string" + ? row.created_at + : typeof row.createdAt === "string" + ? row.createdAt + : undefined; + const updatedAt = + typeof row.updated_at === "string" + ? row.updated_at + : typeof row.updatedAt === "string" + ? row.updatedAt + : undefined; + + return { + ...row, + id, + apiId, + status, + rawStatus, + createdAt, + updatedAt, + }; +} + +export function validateSubscriptionsListResponse(payload: unknown): SubscriptionItem[] { + let list: unknown[] | null = null; + if (Array.isArray(payload)) { + list = payload; + } else if (isRecord(payload) && Array.isArray(payload.data)) { + list = payload.data; + } else if (isRecord(payload) && Array.isArray(payload.subscriptions)) { + list = payload.subscriptions; + } + + if (!list) { + throw new SubscriptionsApiError( + "Invalid subscriptions response: expected an array or { data: [...] }.", + null, + "INVALID_RESPONSE", + ); + } + + return list.map((item) => adaptBackendSubscription(item)); +} + +export function validateSubscriptionResponse(payload: unknown): SubscriptionItem { + if (isRecord(payload) && isRecord(payload.data)) { + return adaptBackendSubscription(payload.data); + } + if (isRecord(payload) && isRecord(payload.subscription)) { + return adaptBackendSubscription(payload.subscription); + } + return adaptBackendSubscription(payload); +} + +export function getSharedAuthenticatedFetch( + baseFetch: typeof fetch = globalThis.fetch, +): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const account = getCurrentAccount(); + const headers = new Headers(init?.headers); + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + if (account?.apiKey && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${account.apiKey}`); + } + return baseFetch(input, { + ...init, + headers, + credentials: init?.credentials ?? "include", + }); + }; +} + +export function isSubscriptionsDemoMode(): boolean { + return !API_BASE; +} + +/** + * List active subscriptions for the authenticated user (`GET /api/subscriptions?status=active`). + */ +export async function listActive( + options: ListSubscriptionsOptions = {}, +): Promise { + const { status = "active", signal, baseUrl, fetchImpl } = options; + + if (signal?.aborted) { + throw abortError(); + } + + const params = new URLSearchParams(); + if (status) { + params.set("status", status); + } + + const query = params.toString() ? `?${params.toString()}` : ""; + const pathWithQuery = `${SUBSCRIPTIONS_PATH}${query}`; + const targetUrl = + baseUrl !== undefined + ? `${baseUrl.replace(/\/+$/, "")}${pathWithQuery}` + : apiUrl(pathWithQuery); + + const effectiveFetch = fetchImpl + ? getSharedAuthenticatedFetch(fetchImpl) + : getSharedAuthenticatedFetch(globalThis.fetch); + + let response: Response; + try { + response = await effectiveFetch(targetUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + signal, + }); + } catch (err) { + if (signal?.aborted || isSubscriptionsAbortError(err)) { + throw abortError(); + } + throw new SubscriptionsApiError( + "Could not reach the subscriptions service. Check your connection and try again.", + null, + "NETWORK_ERROR", + ); + } + + if (signal?.aborted) { + throw abortError(); + } + + if (response.status === 401) { + throw new SubscriptionsApiError( + "Sign in to view your subscriptions.", + 401, + "UNAUTHORIZED", + ); + } + + if (!response.ok) { + throw new SubscriptionsApiError( + "Could not load subscriptions. Please try again.", + response.status, + "HTTP_ERROR", + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (err) { + if (signal?.aborted || isSubscriptionsAbortError(err)) { + throw abortError(); + } + throw new SubscriptionsApiError( + "The subscriptions service returned an unreadable response.", + response.status, + "INVALID_RESPONSE", + ); + } + + if (signal?.aborted) { + throw abortError(); + } + + return validateSubscriptionsListResponse(payload); +} + +/** + * Subscribe to an API (`POST /api/subscriptions` with { api_id }). + * Sends Idempotency-Key header. + */ +export async function subscribe( + apiId: string, + options: SubscribeOptions = {}, +): Promise { + const { idempotencyKey, signal, baseUrl, fetchImpl } = options; + + if (!apiId || !apiId.trim()) { + throw new SubscriptionsApiError("apiId is required to subscribe.", null, "INVALID_RESPONSE"); + } + + if (signal?.aborted) { + throw abortError(); + } + + const targetUrl = + baseUrl !== undefined + ? `${baseUrl.replace(/\/+$/, "")}${SUBSCRIPTIONS_PATH}` + : apiUrl(SUBSCRIPTIONS_PATH); + + const effectiveFetch = fetchImpl + ? getSharedAuthenticatedFetch(fetchImpl) + : getSharedAuthenticatedFetch(globalThis.fetch); + const key = idempotencyKey || generateIdempotencyKey(); + + let response: Response; + try { + response = await effectiveFetch(targetUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "Idempotency-Key": key, + }, + body: JSON.stringify({ api_id: apiId.trim() }), + signal, + }); + } catch (err) { + if (signal?.aborted || isSubscriptionsAbortError(err)) { + throw abortError(); + } + throw new SubscriptionsApiError( + "Could not reach the subscriptions service. Check your connection and try again.", + null, + "NETWORK_ERROR", + ); + } + + if (signal?.aborted) { + throw abortError(); + } + + if (response.status === 401) { + throw new SubscriptionsApiError( + "Sign in to subscribe to this API.", + 401, + "UNAUTHORIZED", + ); + } + + if (response.status === 409) { + throw new SubscriptionsApiError( + "Already subscribed to this API.", + 409, + "CONFLICT", + ); + } + + if (response.status === 404) { + throw new SubscriptionsApiError( + "API not found or no longer available.", + 404, + "NOT_FOUND", + ); + } + + if (!response.ok) { + let errMsg = "Could not subscribe to API. Please try again."; + try { + const errBody = (await response.json()) as { message?: string; error?: string }; + if (errBody?.message) errMsg = errBody.message; + else if (errBody?.error) errMsg = errBody.error; + } catch { + // ignore + } + throw new SubscriptionsApiError(errMsg, response.status, "HTTP_ERROR"); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (err) { + if (signal?.aborted || isSubscriptionsAbortError(err)) { + throw abortError(); + } + throw new SubscriptionsApiError( + "The subscriptions service returned an unreadable response.", + response.status, + "INVALID_RESPONSE", + ); + } + + if (signal?.aborted) { + throw abortError(); + } + + return validateSubscriptionResponse(payload); +} + +/** + * Unsubscribe from a subscription (`DELETE /api/subscriptions/:id`). + */ +export async function unsubscribe( + subscriptionId: string, + options: UnsubscribeOptions = {}, +): Promise { + const { signal, baseUrl, fetchImpl } = options; + + if (!subscriptionId || !subscriptionId.trim()) { + throw new SubscriptionsApiError( + "subscriptionId is required to unsubscribe.", + null, + "INVALID_RESPONSE", + ); + } + + if (signal?.aborted) { + throw abortError(); + } + + const path = `${SUBSCRIPTIONS_PATH}/${encodeURIComponent(subscriptionId.trim())}`; + const targetUrl = + baseUrl !== undefined + ? `${baseUrl.replace(/\/+$/, "")}${path}` + : apiUrl(path); + + const effectiveFetch = fetchImpl + ? getSharedAuthenticatedFetch(fetchImpl) + : getSharedAuthenticatedFetch(globalThis.fetch); + + let response: Response; + try { + response = await effectiveFetch(targetUrl, { + method: "DELETE", + headers: { + Accept: "application/json", + }, + signal, + }); + } catch (err) { + if (signal?.aborted || isSubscriptionsAbortError(err)) { + throw abortError(); + } + throw new SubscriptionsApiError( + "Could not reach the subscriptions service. Check your connection and try again.", + null, + "NETWORK_ERROR", + ); + } + + if (signal?.aborted) { + throw abortError(); + } + + if (response.status === 401) { + throw new SubscriptionsApiError( + "Sign in to manage your subscriptions.", + 401, + "UNAUTHORIZED", + ); + } + + if (response.status === 404) { + throw new SubscriptionsApiError( + "Subscription not found.", + 404, + "NOT_FOUND", + ); + } + + if (!response.ok) { + let errMsg = "Could not unsubscribe. Please try again."; + try { + const errBody = (await response.json()) as { message?: string; error?: string }; + if (errBody?.message) errMsg = errBody.message; + else if (errBody?.error) errMsg = errBody.error; + } catch { + // ignore + } + throw new SubscriptionsApiError(errMsg, response.status, "HTTP_ERROR"); + } +} diff --git a/src/components/SubscribeButton.test.tsx b/src/components/SubscribeButton.test.tsx index 357b13d..4773b9e 100644 --- a/src/components/SubscribeButton.test.tsx +++ b/src/components/SubscribeButton.test.tsx @@ -99,4 +99,142 @@ describe("SubscribeButton", () => { expect(tooltip.textContent).toContain("Subscribing"); expect(tooltip.textContent).toContain("charged per successful API request"); }); + + // ── Scenarios from Test Plan (Issue Requirements) ────────────────────────── + + // Scenario 1: Active subscription exists on load -> button shows Subscribed with Unsubscribe + it("Scenario 1: Active subscription exists on load: button shows Subscribed with Unsubscribe", () => { + render(); + + expect(screen.getByRole("status")).toBeTruthy(); + expect(screen.getByText("Subscribed!")).toBeTruthy(); + const unsubBtn = screen.getByRole("button", { name: "Unsubscribe from Weather API" }); + expect(unsubBtn).toBeTruthy(); + }); + + // Scenario 3: Subscribe 500 -> alert shown, confirmation stays open, no success toast + it("Scenario 3: Subscribe 500: alert shown, confirmation stays open", async () => { + const onSubscribe = vi.fn().mockRejectedValue(new Error("Internal Server Error (500)")); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Subscribe to Weather API" })); + expect(screen.getByRole("dialog")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + await waitFor(() => { + expect(onSubscribe).toHaveBeenCalledOnce(); + }); + + // Confirmation dialog stays open + expect(screen.getByRole("dialog")).toBeTruthy(); + // role="alert" rendered + const alert = screen.getByRole("alert"); + expect(alert).toBeTruthy(); + expect(alert.textContent).toContain("Internal Server Error (500)"); + // Not marked as subscribed + expect(screen.queryByRole("status")).toBeNull(); + }); + + // Scenario 5: Double-click confirm -> exactly one POST/call made + it("Scenario 5: Double-click confirm: exactly one call made due to in-flight guard", async () => { + let resolveSubscribe: () => void = () => {}; + const onSubscribe = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve; + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Subscribe to Weather API" })); + const confirmBtn = screen.getByRole("button", { name: "Confirm" }); + + // Double-click + fireEvent.click(confirmBtn); + fireEvent.click(confirmBtn); + + expect(onSubscribe).toHaveBeenCalledTimes(1); + + resolveSubscribe(); + await waitFor(() => { + expect(screen.getByText("Subscribed!")).toBeTruthy(); + }); + }); + + // Scenario 6: Unsubscribe -> confirmation dialog, onUnsubscribe called, returns to idle/unsubscribed + it("Scenario 6: Unsubscribe flow: prompts confirmation, calls onUnsubscribe, returns to idle", async () => { + const onUnsubscribe = vi.fn().mockResolvedValue(undefined); + const { rerender } = render( + , + ); + + // Click Unsubscribe + const unsubBtn = screen.getByRole("button", { name: "Unsubscribe from Weather API" }); + fireEvent.click(unsubBtn); + + // Unsubscribe confirmation dialog opens + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeTruthy(); + expect(screen.getByText(/Unsubscribe from/)).toBeTruthy(); + + // Click Confirm on unsubscribe + const confirmBtn = screen.getByRole("button", { name: "Confirm" }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(onUnsubscribe).toHaveBeenCalledOnce(); + }); + + // Rerender as unsubscribed + rerender( + , + ); + + // Back to idle + expect(screen.getByRole("button", { name: "Subscribe to Weather API" })).toBeTruthy(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + // Scenario 6b: Unsubscribe failure keeps confirmation open and displays alert + it("Unsubscribe failure: alert shown and confirmation dialog remains open", async () => { + const onUnsubscribe = vi.fn().mockRejectedValue(new Error("Failed to cancel subscription")); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Unsubscribe from Weather API" })); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + await waitFor(() => { + expect(onUnsubscribe).toHaveBeenCalledOnce(); + }); + + // Dialog remains open with alert + expect(screen.getByRole("dialog")).toBeTruthy(); + const alert = screen.getByRole("alert"); + expect(alert).toBeTruthy(); + expect(alert.textContent).toContain("Failed to cancel subscription"); + }); + + // Scenario 8: Demo mode badge rendered + it("Scenario 8: Demo mode renders 'Demo' label and idle subscribe button", () => { + render(); + + expect(screen.getByText("Demo")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Subscribe to Weather API" })).toBeTruthy(); + }); }); diff --git a/src/components/SubscribeButton.tsx b/src/components/SubscribeButton.tsx index 907f792..4eb5221 100644 --- a/src/components/SubscribeButton.tsx +++ b/src/components/SubscribeButton.tsx @@ -2,13 +2,21 @@ import { useState, useRef, useEffect } from "react"; import { CheckIcon } from "./icons/CheckIcon"; import HelpPopover from "./HelpPopover"; -type SubscribeStatus = "idle" | "confirming" | "subscribed"; +type SubscribeStatus = "idle" | "confirming" | "confirming_unsubscribe" | "subscribed"; type Props = { /** Name of the API being subscribed to – shown in the confirmation dialog. */ apiName: string; + /** Whether the user is currently subscribed to this API. */ + isSubscribed?: boolean; + /** Error message to display, if any. */ + error?: string | null; + /** Optional badge/tag label (e.g. "Demo"). */ + badgeLabel?: string; /** Called when the user confirms the subscription. */ onSubscribe?: () => void | Promise; + /** Called when the user confirms unsubscribing. */ + onUnsubscribe?: () => void | Promise; /** Optional CSS class forwarded to the outer element. */ className?: string; }; @@ -21,62 +29,99 @@ type Props = { * 2. User clicks "Confirm" → subscription is registered and a success state * is shown. * 3. "Cancel" returns to the idle state without any side-effects. + * 4. Subscribed state shows "Subscribed" with an "Unsubscribe" button that + * prompts for confirmation before calling onUnsubscribe. + * 5. On error, displays role="alert" message and keeps confirmation dialog open. * * Focus management: When the confirmation dialog opens, focus is moved to the * "Confirm" button so keyboard-only users can immediately confirm or cancel * with a single keystroke. - * - * Part of GrantFox FWC26 campaign UI/UX requirements. Uses design tokens - * instead of hardcoded colors so it respects the active theme. */ -export default function SubscribeButton({ apiName, onSubscribe, className }: Props) { - const [status, setStatus] = useState("idle"); +export default function SubscribeButton({ + apiName, + isSubscribed = false, + error: externalError = null, + badgeLabel, + onSubscribe, + onUnsubscribe, + className, +}: Props) { + const [internalStatus, setInternalStatus] = useState("idle"); const [isLoading, setIsLoading] = useState(false); + const [localError, setLocalError] = useState(null); + const inFlightRef = useRef(false); + const confirmRef = useRef(null); + const unsubscribeConfirmRef = useRef(null); + + const activeError = externalError || localError; useEffect(() => { - if (status === "confirming") { + if (internalStatus === "confirming") { confirmRef.current?.focus(); + } else if (internalStatus === "confirming_unsubscribe") { + unsubscribeConfirmRef.current?.focus(); } - }, [status]); + }, [internalStatus]); const handleSubscribeClick = () => { - setStatus("confirming"); + setLocalError(null); + setInternalStatus("confirming"); + }; + + const handleUnsubscribeClick = () => { + setLocalError(null); + setInternalStatus("confirming_unsubscribe"); }; - const handleConfirm = async () => { + const handleConfirmSubscribe = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; setIsLoading(true); + setLocalError(null); try { await onSubscribe?.(); - setStatus("subscribed"); - } catch { - // Surface the error without crashing; keep the confirmation open. + setInternalStatus("subscribed"); + } catch (err: unknown) { + const msg = + err instanceof Error && err.message + ? err.message + : "Subscription failed. Please try again."; + setLocalError(msg); + // Keep confirmation open } finally { setIsLoading(false); + inFlightRef.current = false; } }; - const handleCancel = () => { - setStatus("idle"); + const handleConfirmUnsubscribe = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setIsLoading(true); + setLocalError(null); + try { + await onUnsubscribe?.(); + setInternalStatus("idle"); + } catch (err: unknown) { + const msg = + err instanceof Error && err.message + ? err.message + : "Unsubscribe failed. Please try again."; + setLocalError(msg); + // Keep confirmation open + } finally { + setIsLoading(false); + inFlightRef.current = false; + } }; - if (status === "subscribed") { - return ( -
-
- ); - } + const handleCancel = () => { + setLocalError(null); + setInternalStatus("idle"); + }; - if (status === "confirming") { + if (internalStatus === "confirming") { return (
-

+

Subscribe to {apiName}? You will receive updates and can manage your subscription at any time.

+ {activeError && ( +
+ {activeError} +
+ )} +
@@ -117,6 +173,116 @@ export default function SubscribeButton({ apiName, onSubscribe, className }: Pro ); } + if (internalStatus === "confirming_unsubscribe") { + return ( +
+

+ Unsubscribe from {apiName}? + You can resubscribe at any time. +

+ + {activeError && ( +
+ {activeError} +
+ )} + +
+ + + +
+
+ ); + } + + const effectiveSubscribed = isSubscribed || internalStatus === "subscribed"; + + if (effectiveSubscribed) { + return ( +
+
+
+ + {badgeLabel && ( + + {badgeLabel} + + )} + + +
+ ); + } + // idle state return ( - showToast(`Subscribed to ${api.name}!`, "success")} /> +
{/* ── Content grid: main column + sidebar ───────────────────────── */}