From 03a4a2915085a39582be60bffaf008530cc02270 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 23:02:42 +0800 Subject: [PATCH] feat(02-03): Subscriber and ServicePlan API routes + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/POST /api/service-plans — list with activeOnly filter, create with validation - PUT /api/service-plans/[id] — partial update via closure pattern - GET/POST /api/subscribers — list/search with status/plan/name filters, paginated; create returns 201 - GET/PUT /api/subscribers/[id] — get with servicePlan relation, profile update - PATCH /api/subscribers/[id]/status — full status lifecycle transitions - All dynamic routes use closure pattern (withPermission HOF + params closure) - 41 tests covering ServicePlan CRUD, Subscriber CRUD, search/filter, status lifecycle, tenant isolation - 162 total tests pass (41 new + 121 existing) --- src/app/api/service-plans/[id]/route.ts | 76 ++ src/app/api/service-plans/route.ts | 107 +++ src/app/api/subscribers/[id]/route.ts | 107 +++ src/app/api/subscribers/[id]/status/route.ts | 75 ++ src/app/api/subscribers/route.ts | 133 ++++ src/lib/__tests__/subscriber.test.ts | 709 +++++++++++++++++++ 6 files changed, 1207 insertions(+) create mode 100644 src/app/api/service-plans/[id]/route.ts create mode 100644 src/app/api/service-plans/route.ts create mode 100644 src/app/api/subscribers/[id]/route.ts create mode 100644 src/app/api/subscribers/[id]/status/route.ts create mode 100644 src/app/api/subscribers/route.ts create mode 100644 src/lib/__tests__/subscriber.test.ts diff --git a/src/app/api/service-plans/[id]/route.ts b/src/app/api/service-plans/[id]/route.ts new file mode 100644 index 0000000..c4e9b53 --- /dev/null +++ b/src/app/api/service-plans/[id]/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { updateServicePlan } from "@/lib/services/service-plan-service"; +import { BillingType } from "@prisma/client"; + +/** + * PUT /api/service-plans/[id] + * + * Partially update a service plan. + * Accepts: { name?, speed?, monthlyPrice?, billingType?, description?, isActive? } + * + * Requires: manage on Subscriber subject. + * + * Response: + * 200 OK — updated service plan object + * 400 Bad Request — validation error + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + * 404 Not Found — plan not found in tenant scope + */ +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const updates = body as Record; + + // Validate billingType if provided + if ( + updates.billingType !== undefined && + !Object.values(BillingType).includes(updates.billingType as BillingType) + ) { + return NextResponse.json( + { error: `billingType must be one of: ${Object.values(BillingType).join(", ")}` }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const plan = await updateServicePlan(tenantPrisma, id, { + name: updates.name as string | undefined, + speed: updates.speed as string | undefined, + monthlyPrice: updates.monthlyPrice as number | undefined, + billingType: updates.billingType as BillingType | undefined, + description: updates.description as string | undefined, + isActive: updates.isActive as boolean | undefined, + }); + return NextResponse.json(plan); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update service plan"; + // Prisma throws P2025 for record not found + if (message.includes("Record to update not found") || message.includes("P2025")) { + return NextResponse.json({ error: "Service plan not found" }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/service-plans/route.ts b/src/app/api/service-plans/route.ts new file mode 100644 index 0000000..41dd009 --- /dev/null +++ b/src/app/api/service-plans/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { + createServicePlan, + listServicePlans, +} from "@/lib/services/service-plan-service"; +import { BillingType } from "@prisma/client"; + +/** + * GET /api/service-plans + * + * List service plans for the authenticated tenant. + * Accepts: ?activeOnly=true|false (default true) + * + * Requires: read on Subscriber subject. + * + * Response: + * 200 OK — Array of service plan objects + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export const GET = withPermission("read", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const activeOnlyParam = searchParams.get("activeOnly"); + const activeOnly = activeOnlyParam === null ? true : activeOnlyParam !== "false"; + + const tenantPrisma = withTenantContext(user.tenantId); + const plans = await listServicePlans(tenantPrisma, { activeOnly }); + + return NextResponse.json(plans); + } +); + +/** + * POST /api/service-plans + * + * Create a new service plan. + * Accepts: { name, speed, monthlyPrice, billingType, description? } + * + * Requires: manage on Subscriber subject. + * + * Response: + * 201 Created — created service plan object + * 400 Bad Request — validation error or duplicate name + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export const POST = withPermission("manage", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { name, speed, monthlyPrice, billingType, description } = body as Record; + + if (!name || typeof name !== "string") { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + if (!speed || typeof speed !== "string") { + return NextResponse.json({ error: "speed is required" }, { status: 400 }); + } + if (typeof monthlyPrice !== "number" || isNaN(monthlyPrice)) { + return NextResponse.json({ error: "monthlyPrice must be a number" }, { status: 400 }); + } + if (!billingType || !Object.values(BillingType).includes(billingType as BillingType)) { + return NextResponse.json( + { error: `billingType must be one of: ${Object.values(BillingType).join(", ")}` }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const plan = await createServicePlan(tenantPrisma, { + name, + speed, + monthlyPrice, + billingType: billingType as BillingType, + description: description as string | undefined, + }); + return NextResponse.json(plan, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create service plan"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); diff --git a/src/app/api/subscribers/[id]/route.ts b/src/app/api/subscribers/[id]/route.ts new file mode 100644 index 0000000..9f1e18d --- /dev/null +++ b/src/app/api/subscribers/[id]/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { + getSubscriber, + updateSubscriber, +} from "@/lib/services/subscriber-service"; + +/** + * GET /api/subscribers/[id] + * + * Get subscriber detail with service plan included. + * + * Requires: read on Subscriber subject. + * + * Response: + * 200 OK — subscriber with servicePlan relation + * 404 Not Found — subscriber not found in tenant scope + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Subscriber")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + const tenantPrisma = withTenantContext(user.tenantId); + const subscriber = await getSubscriber(tenantPrisma, id); + + if (!subscriber) { + return NextResponse.json({ error: "Subscriber not found" }, { status: 404 }); + } + + return NextResponse.json(subscriber); + } + )(req); +} + +/** + * PUT /api/subscribers/[id] + * + * Update subscriber profile fields. + * Status changes must use PATCH /api/subscribers/[id]/status. + * Accepts: { firstName?, lastName?, email?, phone?, address?, zone?, servicePlanId?, notes? } + * + * Requires: manage on Subscriber subject. + * + * Response: + * 200 OK — updated subscriber with servicePlan + * 400 Bad Request — validation error + * 404 Not Found — subscriber not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const updates = body as Record; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const subscriber = await updateSubscriber(tenantPrisma, id, { + firstName: updates.firstName as string | undefined, + lastName: updates.lastName as string | undefined, + email: updates.email as string | undefined, + phone: updates.phone as string | undefined, + address: updates.address as string | undefined, + zone: updates.zone as string | undefined, + servicePlanId: updates.servicePlanId as string | undefined, + notes: updates.notes as string | undefined, + autoSuspendDays: updates.autoSuspendDays as number | null | undefined, + }); + return NextResponse.json(subscriber); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update subscriber"; + if (message.includes("Record to update not found") || message.includes("P2025")) { + return NextResponse.json({ error: "Subscriber not found" }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/subscribers/[id]/status/route.ts b/src/app/api/subscribers/[id]/status/route.ts new file mode 100644 index 0000000..41b51dd --- /dev/null +++ b/src/app/api/subscribers/[id]/status/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { changeSubscriberStatus } from "@/lib/services/subscriber-service"; +import { SubscriberStatus } from "@prisma/client"; + +/** + * PATCH /api/subscribers/[id]/status + * + * Change subscriber status through the defined lifecycle. + * + * Valid transitions: + * ACTIVE -> SUSPENDED | CANCELLED + * SUSPENDED -> ACTIVE | CANCELLED + * CANCELLED -> ACTIVE + * + * Accepts: { status: "ACTIVE"|"SUSPENDED"|"CANCELLED", reason? } + * + * Requires: manage on Subscriber subject. + * + * Response: + * 200 OK — updated subscriber with servicePlan + * 400 Bad Request — invalid status value or invalid transition + * 404 Not Found — subscriber not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { status, reason } = body as Record; + + if (!status || !Object.values(SubscriberStatus).includes(status as SubscriberStatus)) { + return NextResponse.json( + { error: `status must be one of: ${Object.values(SubscriberStatus).join(", ")}` }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const subscriber = await changeSubscriberStatus( + tenantPrisma, + id, + status as SubscriberStatus, + reason as string | undefined + ); + return NextResponse.json(subscriber); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to change subscriber status"; + if (message === "Subscriber not found") { + return NextResponse.json({ error: "Subscriber not found" }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/subscribers/route.ts b/src/app/api/subscribers/route.ts new file mode 100644 index 0000000..a7e4fb4 --- /dev/null +++ b/src/app/api/subscribers/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { + createSubscriber, + searchSubscribers, +} from "@/lib/services/subscriber-service"; +import { SubscriberStatus } from "@prisma/client"; + +/** + * GET /api/subscribers + * + * List and search subscribers for the authenticated tenant. + * Accepts: ?status=&servicePlanId=&search=&page=&pageSize= + * + * Requires: read on Subscriber subject. + * + * Response: + * 200 OK — { subscribers, total, page, pageSize } + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export const GET = withPermission("read", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const status = searchParams.get("status") as SubscriberStatus | null; + const servicePlanId = searchParams.get("servicePlanId") ?? undefined; + const search = searchParams.get("search") ?? undefined; + const page = parseInt(searchParams.get("page") ?? "1", 10); + const pageSize = parseInt(searchParams.get("pageSize") ?? "20", 10); + + // Validate status if provided + if (status && !Object.values(SubscriberStatus).includes(status)) { + return NextResponse.json( + { error: `status must be one of: ${Object.values(SubscriberStatus).join(", ")}` }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + const result = await searchSubscribers(tenantPrisma, { + status: status ?? undefined, + servicePlanId, + search, + page: isNaN(page) ? 1 : page, + pageSize: isNaN(pageSize) ? 20 : pageSize, + }); + + return NextResponse.json(result); + } +); + +/** + * POST /api/subscribers + * + * Register a new subscriber. + * Accepts: { firstName, lastName, email?, phone?, address, zone?, servicePlanId, notes? } + * + * Requires: manage on Subscriber subject. + * + * Response: + * 201 Created — created subscriber with servicePlan included + * 400 Bad Request — validation error + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export const POST = withPermission("manage", "Subscriber")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { + firstName, + lastName, + email, + phone, + address, + zone, + servicePlanId, + notes, + } = body as Record; + + if (!firstName || typeof firstName !== "string") { + return NextResponse.json({ error: "firstName is required" }, { status: 400 }); + } + if (!lastName || typeof lastName !== "string") { + return NextResponse.json({ error: "lastName is required" }, { status: 400 }); + } + if (!address || typeof address !== "string") { + return NextResponse.json({ error: "address is required" }, { status: 400 }); + } + if (!servicePlanId || typeof servicePlanId !== "string") { + return NextResponse.json({ error: "servicePlanId is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const subscriber = await createSubscriber(tenantPrisma, { + firstName, + lastName, + email: email as string | undefined, + phone: phone as string | undefined, + address, + zone: zone as string | undefined, + servicePlanId, + notes: notes as string | undefined, + }); + return NextResponse.json(subscriber, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create subscriber"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); diff --git a/src/lib/__tests__/subscriber.test.ts b/src/lib/__tests__/subscriber.test.ts new file mode 100644 index 0000000..1fec822 --- /dev/null +++ b/src/lib/__tests__/subscriber.test.ts @@ -0,0 +1,709 @@ +/** + * Subscriber and ServicePlan Integration Tests + * + * Tests the subscriber management and service plan CRUD operations. + * These tests require a live PostgreSQL database connection. + * + * WHAT IS TESTED: + * - ServicePlan CRUD (create, update, list, deactivate) + * - Subscriber CRUD (register, update, get) + * - Account number auto-generation (SUB-0001, SUB-0002, …) + * - billingDay derived from signup date (capped at 28) + * - Status lifecycle (all valid transitions + invalid transition rejection) + * - Search by name (partial match, case-insensitive) + * - Filter by status and servicePlanId + * - Pagination (page, pageSize, total) + * - Tenant isolation (Tenant A data not visible to Tenant B) + * + * ISOLATION STRATEGY: + * Two test tenants created in beforeAll. afterAll cleans up by deleting + * both test tenants (cascade deletes all subscriber and plan data). + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { + createServicePlan, + updateServicePlan, + listServicePlans, + deactivateServicePlan, +} from "@/lib/services/service-plan-service"; +import { + createSubscriber, + updateSubscriber, + changeSubscriberStatus, + searchSubscribers, + getSubscriber, + generateAccountNumber, +} from "@/lib/services/subscriber-service"; +import { BillingType, SubscriberStatus, TenantStatus } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TEST_TIMESTAMP = Date.now(); + +let tenantAId: string; +let tenantBId: string; + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create Tenant A (primary test tenant) + const tenantA = await prisma.tenant.create({ + data: { + name: `Subscriber Test Tenant A ${TEST_TIMESTAMP}`, + slug: `sub-test-a-${TEST_TIMESTAMP}`, + ownerEmail: `sub-owner-a-${TEST_TIMESTAMP}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantAId = tenantA.id; + + // Create Tenant B (for isolation tests) + const tenantB = await prisma.tenant.create({ + data: { + name: `Subscriber Test Tenant B ${TEST_TIMESTAMP}`, + slug: `sub-test-b-${TEST_TIMESTAMP}`, + ownerEmail: `sub-owner-b-${TEST_TIMESTAMP}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantBId = tenantB.id; +}); + +afterAll(async () => { + if (tenantAId) { + await prisma.tenant.delete({ where: { id: tenantAId } }).catch(() => {}); + } + if (tenantBId) { + await prisma.tenant.delete({ where: { id: tenantBId } }).catch(() => {}); + } + await prisma.$disconnect(); +}); + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +function tenantA() { + return withTenantContext(tenantAId); +} + +function tenantB() { + return withTenantContext(tenantBId); +} + +// =========================================================================== +// SERVICE PLAN TESTS +// =========================================================================== + +describe("ServicePlan CRUD", () => { + let planId: string; + + it("creates a plan with valid data", async () => { + const plan = await createServicePlan(tenantA(), { + name: `Basic Plan ${TEST_TIMESTAMP}`, + speed: "10 Mbps", + monthlyPrice: 29.99, + billingType: BillingType.PREPAID, + description: "Entry level internet", + }); + + planId = plan.id; + expect(plan.name).toBe(`Basic Plan ${TEST_TIMESTAMP}`); + expect(plan.speed).toBe("10 Mbps"); + expect(Number(plan.monthlyPrice)).toBeCloseTo(29.99); + expect(plan.billingType).toBe(BillingType.PREPAID); + expect(plan.isActive).toBe(true); + expect(plan.tenantId).toBe(tenantAId); + }); + + it("fails to create plan with duplicate name in same tenant", async () => { + await expect( + createServicePlan(tenantA(), { + name: `Basic Plan ${TEST_TIMESTAMP}`, + speed: "20 Mbps", + monthlyPrice: 49.99, + billingType: BillingType.POSTPAID, + }) + ).rejects.toThrow(); + }); + + it("allows same plan name in different tenants", async () => { + const plan = await createServicePlan(tenantB(), { + name: `Basic Plan ${TEST_TIMESTAMP}`, + speed: "10 Mbps", + monthlyPrice: 29.99, + billingType: BillingType.PREPAID, + }); + expect(plan.tenantId).toBe(tenantBId); + }); + + it("fails to create plan with zero price", async () => { + await expect( + createServicePlan(tenantA(), { + name: "Zero Price Plan", + speed: "10 Mbps", + monthlyPrice: 0, + billingType: BillingType.PREPAID, + }) + ).rejects.toThrow("Monthly price must be greater than 0"); + }); + + it("fails to create plan with negative price", async () => { + await expect( + createServicePlan(tenantA(), { + name: "Negative Price Plan", + speed: "10 Mbps", + monthlyPrice: -5, + billingType: BillingType.PREPAID, + }) + ).rejects.toThrow("Monthly price must be greater than 0"); + }); + + it("fails to create plan with empty name", async () => { + await expect( + createServicePlan(tenantA(), { + name: "", + speed: "10 Mbps", + monthlyPrice: 29.99, + billingType: BillingType.PREPAID, + }) + ).rejects.toThrow("Service plan name is required"); + }); + + it("lists only active plans by default", async () => { + // Create an inactive plan + const inactivePlan = await createServicePlan(tenantA(), { + name: `Soon Inactive Plan ${TEST_TIMESTAMP}`, + speed: "5 Mbps", + monthlyPrice: 19.99, + billingType: BillingType.PREPAID, + }); + await deactivateServicePlan(tenantA(), inactivePlan.id); + + const plans = await listServicePlans(tenantA()); + const planIds = plans.map((p) => p.id); + expect(planIds).not.toContain(inactivePlan.id); + }); + + it("lists all plans when activeOnly=false", async () => { + const plans = await listServicePlans(tenantA(), { activeOnly: false }); + const hasInactive = plans.some((p) => !p.isActive); + expect(hasInactive).toBe(true); + }); + + it("updates plan fields", async () => { + const updated = await updateServicePlan(tenantA(), planId, { + speed: "20 Mbps", + monthlyPrice: 39.99, + }); + expect(updated.speed).toBe("20 Mbps"); + expect(Number(updated.monthlyPrice)).toBeCloseTo(39.99); + // Unchanged fields preserved + expect(updated.billingType).toBe(BillingType.PREPAID); + }); + + it("deactivates plan (soft-delete)", async () => { + const plan = await createServicePlan(tenantA(), { + name: `Plan To Deactivate ${TEST_TIMESTAMP}`, + speed: "50 Mbps", + monthlyPrice: 59.99, + billingType: BillingType.POSTPAID, + }); + const deactivated = await deactivateServicePlan(tenantA(), plan.id); + expect(deactivated.isActive).toBe(false); + + // Does not appear in active list + const activePlans = await listServicePlans(tenantA()); + expect(activePlans.map((p) => p.id)).not.toContain(plan.id); + }); + + it("returns plans ordered by name", async () => { + const plans = await listServicePlans(tenantA(), { activeOnly: false }); + const names = plans.map((p) => p.name); + const sorted = [...names].sort(); + expect(names).toEqual(sorted); + }); +}); + +// =========================================================================== +// SUBSCRIBER CRUD TESTS +// =========================================================================== + +describe("Subscriber CRUD", () => { + let planId: string; + let subscriberId: string; + + beforeAll(async () => { + const plan = await createServicePlan(tenantA(), { + name: `CRUD Test Plan ${TEST_TIMESTAMP}`, + speed: "50 Mbps", + monthlyPrice: 49.99, + billingType: BillingType.PREPAID, + }); + planId = plan.id; + }); + + it("registers subscriber with all fields, accountNumber auto-generated", async () => { + const sub = await createSubscriber(tenantA(), { + firstName: "Alice", + lastName: "Smith", + email: `alice-${TEST_TIMESTAMP}@example.com`, + phone: "+1-555-0100", + address: "123 Main St, Springfield", + zone: "Zone A", + servicePlanId: planId, + notes: "Test subscriber", + }); + + subscriberId = sub.id; + expect(sub.firstName).toBe("Alice"); + expect(sub.lastName).toBe("Smith"); + expect(sub.accountNumber).toMatch(/^SUB-\d{4,}$/); + expect(sub.status).toBe(SubscriberStatus.ACTIVE); + expect(sub.tenantId).toBe(tenantAId); + expect(sub.servicePlanId).toBe(planId); + expect(sub.servicePlan).toBeDefined(); + expect(sub.servicePlan.id).toBe(planId); + }); + + it("account numbers are sequential: SUB-0001, SUB-0002, …", async () => { + const first = await createSubscriber(tenantA(), { + firstName: "Bob", + lastName: "Jones", + address: "456 Oak Ave", + servicePlanId: planId, + }); + + const second = await createSubscriber(tenantA(), { + firstName: "Carol", + lastName: "White", + address: "789 Pine Rd", + servicePlanId: planId, + }); + + const firstNum = parseInt(first.accountNumber.replace("SUB-", ""), 10); + const secondNum = parseInt(second.accountNumber.replace("SUB-", ""), 10); + expect(secondNum).toBe(firstNum + 1); + }); + + it("billingDay is derived from signup date and capped at 28", async () => { + const sub = await createSubscriber(tenantA(), { + firstName: "Dan", + lastName: "Brown", + address: "1 Test Lane", + servicePlanId: planId, + }); + + expect(sub.billingDay).toBeGreaterThanOrEqual(1); + expect(sub.billingDay).toBeLessThanOrEqual(28); + }); + + it("fails to register with invalid servicePlanId", async () => { + await expect( + createSubscriber(tenantA(), { + firstName: "Eve", + lastName: "Davis", + address: "2 Fake St", + servicePlanId: "00000000-0000-0000-0000-000000000000", + }) + ).rejects.toThrow("Service plan not found or is inactive"); + }); + + it("fails to register with inactive servicePlan", async () => { + const inactivePlan = await createServicePlan(tenantA(), { + name: `Inactive For Subscriber ${TEST_TIMESTAMP}`, + speed: "1 Mbps", + monthlyPrice: 9.99, + billingType: BillingType.PREPAID, + }); + await deactivateServicePlan(tenantA(), inactivePlan.id); + + await expect( + createSubscriber(tenantA(), { + firstName: "Eve", + lastName: "Davis", + address: "2 Fake St", + servicePlanId: inactivePlan.id, + }) + ).rejects.toThrow("Service plan not found or is inactive"); + }); + + it("fails to register with missing required fields", async () => { + await expect( + createSubscriber(tenantA(), { + firstName: "", + lastName: "Test", + address: "123 Test St", + servicePlanId: planId, + }) + ).rejects.toThrow("First name is required"); + + await expect( + createSubscriber(tenantA(), { + firstName: "Test", + lastName: "Test", + address: "", + servicePlanId: planId, + }) + ).rejects.toThrow("Address is required"); + }); + + it("updates subscriber profile fields", async () => { + const updated = await updateSubscriber(tenantA(), subscriberId, { + phone: "+1-555-9999", + address: "999 Updated St", + notes: "Updated notes", + }); + + expect(updated.phone).toBe("+1-555-9999"); + expect(updated.address).toBe("999 Updated St"); + expect(updated.notes).toContain("Updated notes"); + // Unchanged fields preserved + expect(updated.firstName).toBe("Alice"); + }); + + it("get subscriber includes servicePlan relation", async () => { + const sub = await getSubscriber(tenantA(), subscriberId); + expect(sub).not.toBeNull(); + expect(sub!.servicePlan).toBeDefined(); + expect(sub!.servicePlan.id).toBe(planId); + }); + + it("get subscriber returns null for non-existent id", async () => { + const sub = await getSubscriber( + tenantA(), + "00000000-0000-0000-0000-000000000000" + ); + expect(sub).toBeNull(); + }); +}); + +// =========================================================================== +// SEARCH AND FILTER TESTS +// =========================================================================== + +describe("Subscriber search and filter", () => { + let planId: string; + let plan2Id: string; + + beforeAll(async () => { + const plan = await createServicePlan(tenantA(), { + name: `Search Test Plan 1 ${TEST_TIMESTAMP}`, + speed: "100 Mbps", + monthlyPrice: 79.99, + billingType: BillingType.POSTPAID, + }); + planId = plan.id; + + const plan2 = await createServicePlan(tenantA(), { + name: `Search Test Plan 2 ${TEST_TIMESTAMP}`, + speed: "200 Mbps", + monthlyPrice: 99.99, + billingType: BillingType.POSTPAID, + }); + plan2Id = plan2.id; + + // Create test subscribers for search + await createSubscriber(tenantA(), { + firstName: "SearchAlpha", + lastName: "Findme", + address: "1 Search St", + servicePlanId: planId, + }); + await createSubscriber(tenantA(), { + firstName: "SearchBeta", + lastName: "Findme", + address: "2 Search St", + servicePlanId: planId, + }); + await createSubscriber(tenantA(), { + firstName: "SearchGamma", + lastName: "Other", + address: "3 Search St", + servicePlanId: plan2Id, + }); + }); + + it("searches by partial firstName (case-insensitive)", async () => { + const result = await searchSubscribers(tenantA(), { search: "searchalpha" }); + expect(result.subscribers.length).toBeGreaterThanOrEqual(1); + expect( + result.subscribers.some((s) => s.firstName === "SearchAlpha") + ).toBe(true); + }); + + it("searches by partial lastName", async () => { + const result = await searchSubscribers(tenantA(), { search: "Findme" }); + expect(result.subscribers.length).toBeGreaterThanOrEqual(2); + expect(result.subscribers.every((s) => s.lastName === "Findme")).toBe(true); + }); + + it("searches by partial name (firstName OR lastName)", async () => { + const result = await searchSubscribers(tenantA(), { search: "searchgamma" }); + expect(result.subscribers.length).toBeGreaterThanOrEqual(1); + expect(result.subscribers.some((s) => s.firstName === "SearchGamma")).toBe(true); + }); + + it("filters by status", async () => { + // Suspend one subscriber + const sub = await createSubscriber(tenantA(), { + firstName: "Suspended", + lastName: "Subscriber", + address: "4 Search St", + servicePlanId: planId, + }); + await changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.SUSPENDED); + + const result = await searchSubscribers(tenantA(), { + status: SubscriberStatus.SUSPENDED, + }); + expect(result.subscribers.length).toBeGreaterThanOrEqual(1); + expect(result.subscribers.every((s) => s.status === SubscriberStatus.SUSPENDED)).toBe(true); + }); + + it("filters by servicePlanId", async () => { + const result = await searchSubscribers(tenantA(), { servicePlanId: plan2Id }); + expect(result.subscribers.length).toBeGreaterThanOrEqual(1); + expect(result.subscribers.every((s) => s.servicePlanId === plan2Id)).toBe(true); + }); + + it("pagination: page and pageSize work correctly", async () => { + const page1 = await searchSubscribers(tenantA(), { page: 1, pageSize: 2 }); + const page2 = await searchSubscribers(tenantA(), { page: 2, pageSize: 2 }); + + expect(page1.subscribers.length).toBe(2); + expect(page1.page).toBe(1); + expect(page1.pageSize).toBe(2); + expect(page2.page).toBe(2); + + // No overlap between pages + const page1Ids = page1.subscribers.map((s) => s.id); + const page2Ids = page2.subscribers.map((s) => s.id); + const overlap = page1Ids.filter((id) => page2Ids.includes(id)); + expect(overlap).toHaveLength(0); + }); + + it("returns correct total count", async () => { + const result = await searchSubscribers(tenantA(), { pageSize: 100 }); + expect(result.total).toBeGreaterThanOrEqual(result.subscribers.length); + }); +}); + +// =========================================================================== +// STATUS LIFECYCLE TESTS +// =========================================================================== + +describe("Subscriber status lifecycle", () => { + let planId: string; + + beforeAll(async () => { + const plan = await createServicePlan(tenantA(), { + name: `Status Test Plan ${TEST_TIMESTAMP}`, + speed: "25 Mbps", + monthlyPrice: 34.99, + billingType: BillingType.PREPAID, + }); + planId = plan.id; + }); + + async function freshSubscriber(suffix: string) { + return createSubscriber(tenantA(), { + firstName: `Status${suffix}`, + lastName: "Test", + address: `${suffix} Status Ln`, + servicePlanId: planId, + }); + } + + it("ACTIVE -> SUSPENDED sets suspendedAt", async () => { + const sub = await freshSubscriber("AS"); + expect(sub.status).toBe(SubscriberStatus.ACTIVE); + + const suspended = await changeSubscriberStatus( + tenantA(), + sub.id, + SubscriberStatus.SUSPENDED, + "Overdue balance" + ); + expect(suspended.status).toBe(SubscriberStatus.SUSPENDED); + expect(suspended.suspendedAt).not.toBeNull(); + expect(suspended.cancelledAt).toBeNull(); + }); + + it("ACTIVE -> CANCELLED sets cancelledAt", async () => { + const sub = await freshSubscriber("AC"); + const cancelled = await changeSubscriberStatus( + tenantA(), + sub.id, + SubscriberStatus.CANCELLED + ); + expect(cancelled.status).toBe(SubscriberStatus.CANCELLED); + expect(cancelled.cancelledAt).not.toBeNull(); + }); + + it("SUSPENDED -> ACTIVE clears suspendedAt", async () => { + const sub = await freshSubscriber("SA"); + await changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.SUSPENDED); + const reactivated = await changeSubscriberStatus( + tenantA(), + sub.id, + SubscriberStatus.ACTIVE + ); + expect(reactivated.status).toBe(SubscriberStatus.ACTIVE); + expect(reactivated.suspendedAt).toBeNull(); + }); + + it("SUSPENDED -> CANCELLED sets cancelledAt", async () => { + const sub = await freshSubscriber("SC"); + await changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.SUSPENDED); + const cancelled = await changeSubscriberStatus( + tenantA(), + sub.id, + SubscriberStatus.CANCELLED + ); + expect(cancelled.status).toBe(SubscriberStatus.CANCELLED); + expect(cancelled.cancelledAt).not.toBeNull(); + }); + + it("CANCELLED -> ACTIVE clears both timestamps (reversible cancellation)", async () => { + const sub = await freshSubscriber("CA"); + await changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.CANCELLED); + const reactivated = await changeSubscriberStatus( + tenantA(), + sub.id, + SubscriberStatus.ACTIVE + ); + expect(reactivated.status).toBe(SubscriberStatus.ACTIVE); + expect(reactivated.suspendedAt).toBeNull(); + expect(reactivated.cancelledAt).toBeNull(); + }); + + it("rejects invalid transition: ACTIVE -> ACTIVE", async () => { + const sub = await freshSubscriber("AA"); + await expect( + changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.ACTIVE) + ).rejects.toThrow("Invalid status transition"); + }); + + it("rejects invalid transition: CANCELLED -> SUSPENDED", async () => { + const sub = await freshSubscriber("CS"); + await changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.CANCELLED); + await expect( + changeSubscriberStatus(tenantA(), sub.id, SubscriberStatus.SUSPENDED) + ).rejects.toThrow("Invalid status transition"); + }); + + it("reason appended to subscriber notes", async () => { + const sub = await freshSubscriber("Reason"); + const suspended = await changeSubscriberStatus( + tenantA(), + sub.id, + SubscriberStatus.SUSPENDED, + "Did not pay for 60 days" + ); + expect(suspended.notes).toContain("Did not pay for 60 days"); + expect(suspended.notes).toContain("SUSPENDED"); + }); + + it("throws for non-existent subscriber", async () => { + await expect( + changeSubscriberStatus( + tenantA(), + "00000000-0000-0000-0000-000000000000", + SubscriberStatus.SUSPENDED + ) + ).rejects.toThrow("Subscriber not found"); + }); +}); + +// =========================================================================== +// ACCOUNT NUMBER FORMAT TESTS +// =========================================================================== + +describe("Account number generation", () => { + let planId: string; + + beforeAll(async () => { + const plan = await createServicePlan(tenantB(), { + name: `AcctNum Test Plan ${TEST_TIMESTAMP}`, + speed: "10 Mbps", + monthlyPrice: 19.99, + billingType: BillingType.PREPAID, + }); + planId = plan.id; + }); + + it("first subscriber in tenant gets SUB-0001 format", async () => { + // Tenant B has no subscribers yet + const accountNumber = await generateAccountNumber(tenantB()); + expect(accountNumber).toMatch(/^SUB-\d{4,}$/); + expect(accountNumber).toBe("SUB-0001"); + }); + + it("account numbers are sequential per tenant", async () => { + const sub1 = await createSubscriber(tenantB(), { + firstName: "First", + lastName: "SequentialTest", + address: "1 Seq St", + servicePlanId: planId, + }); + const sub2 = await createSubscriber(tenantB(), { + firstName: "Second", + lastName: "SequentialTest", + address: "2 Seq St", + servicePlanId: planId, + }); + expect(sub1.accountNumber).toBe("SUB-0001"); + expect(sub2.accountNumber).toBe("SUB-0002"); + }); +}); + +// =========================================================================== +// TENANT ISOLATION TESTS +// =========================================================================== + +describe("Tenant isolation", () => { + let planAId: string; + let subAId: string; + + beforeAll(async () => { + const plan = await createServicePlan(tenantA(), { + name: `Isolation Plan A ${TEST_TIMESTAMP}`, + speed: "10 Mbps", + monthlyPrice: 24.99, + billingType: BillingType.PREPAID, + }); + planAId = plan.id; + + const sub = await createSubscriber(tenantA(), { + firstName: "IsolationTest", + lastName: "TenantA", + address: "1 Isolation Rd", + servicePlanId: planAId, + }); + subAId = sub.id; + }); + + it("subscriber from Tenant A is not visible to Tenant B", async () => { + const result = await searchSubscribers(tenantB(), {}); + const ids = result.subscribers.map((s) => s.id); + expect(ids).not.toContain(subAId); + }); + + it("getSubscriber from wrong tenant returns null", async () => { + const sub = await getSubscriber(tenantB(), subAId); + expect(sub).toBeNull(); + }); + + it("service plan from Tenant A is not visible to Tenant B", async () => { + const plans = await listServicePlans(tenantB(), { activeOnly: false }); + const ids = plans.map((p) => p.id); + expect(ids).not.toContain(planAId); + }); +});