feat(02-03): Subscriber and ServicePlan API routes + tests

- 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)
This commit is contained in:
kevin-asprec
2026-03-04 23:02:42 +08:00
parent 9cc6af14e9
commit 03a4a29150
6 changed files with 1207 additions and 0 deletions

View File

@@ -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);
});
});