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,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<string, unknown>;
// 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);
}

View File

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

View File

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

View File

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

View File

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

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