Files
beenvoice/apps/mobile/tests/cross-client-parity.test.ts
T

116 lines
3.8 KiB
TypeScript

/// <reference types="bun" />
import { afterEach, describe, expect, test } from "bun:test";
import {
EXPENSE_CATEGORIES as domainExpenseCategories,
formatElapsedSeconds as formatDomainElapsedSeconds,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
import { fetchAuthCapabilities } from "../lib/auth-capabilities";
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
import { getInvoiceStatus } from "../lib/invoice-status";
import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/invoice-number";
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import {
normalizeOptionalId,
} from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("auth contract smoke checks", () => {
test("mobile reads the server capabilities payload", async () => {
globalThis.fetch = (async (input) => {
expect(String(input)).toBe("https://beenvoice.app/api/auth/capabilities");
return Response.json({ authentik: true, signupsDisabled: true });
}) as typeof fetch;
await expect(
fetchAuthCapabilities("https://beenvoice.app/"),
).resolves.toEqual({
authentik: true,
signupsDisabled: true,
});
});
test("web auth callbacks stay on the same origin", () => {
expect(safeCallbackPath("/dashboard/invoices?status=sent")).toBe(
"/dashboard/invoices?status=sent",
);
expect(safeCallbackPath("https://attacker.example")).toBe("/dashboard");
expect(safeCallbackPath("//attacker.example")).toBe("/dashboard");
});
});
describe("invoice parity", () => {
test("web and mobile use the device-local date in invoice numbers", () => {
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
});
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const fixtures = [
{
stored: "draft" as const,
dueDate: yesterday,
expected: "draft" as const,
},
{
stored: "paid" as const,
dueDate: yesterday,
expected: "paid" as const,
},
{ stored: "sent" as const, dueDate: today, expected: "sent" as const },
{
stored: "sent" as const,
dueDate: yesterday,
expected: "overdue" as const,
},
];
for (const fixture of fixtures) {
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
fixture.expected,
);
expect(
getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }),
).toBe(fixture.expected);
}
});
});
describe("timer parity", () => {
test("elapsed time formatting is identical", () => {
for (const seconds of [0, 59, 60, 3_661, 86_399]) {
expect(formatAppElapsedSeconds(seconds)).toBe(
formatDomainElapsedSeconds(seconds),
);
}
});
test("server normalizes optional client identifiers", () => {
expect(normalizeOptionalId(undefined)).toBeNull();
expect(normalizeOptionalId(" ")).toBeNull();
expect(normalizeOptionalId(" client-1 ")).toBe("client-1");
});
});
describe("expense parity", () => {
test("web and mobile expose the same category vocabulary", () => {
expect([...appExpenseCategories]).toEqual([...domainExpenseCategories]);
});
});