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:
709
src/lib/__tests__/subscriber.test.ts
Normal file
709
src/lib/__tests__/subscriber.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user