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:
76
src/app/api/service-plans/[id]/route.ts
Normal file
76
src/app/api/service-plans/[id]/route.ts
Normal 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);
|
||||
}
|
||||
107
src/app/api/service-plans/route.ts
Normal file
107
src/app/api/service-plans/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
107
src/app/api/subscribers/[id]/route.ts
Normal file
107
src/app/api/subscribers/[id]/route.ts
Normal 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);
|
||||
}
|
||||
75
src/app/api/subscribers/[id]/status/route.ts
Normal file
75
src/app/api/subscribers/[id]/status/route.ts
Normal 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);
|
||||
}
|
||||
133
src/app/api/subscribers/route.ts
Normal file
133
src/app/api/subscribers/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
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