import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common"; import { describe, expect, it, vi } from "vitest"; import { BillingService } from "./billing.service"; /** * Drizzle-shaped mock: every `db.select()` pops the next row-set from `selects` * (order = order of queries in the service), `db.insert()` returns * `insertRows` from `.returning()` and records `.values()` payloads. */ function createMockDb(selects: unknown[][], insertRows: unknown[] = []) { const queue = [...selects]; const inserted: unknown[] = []; function chain(terminal: unknown, onValues?: (v: unknown) => void) { const c: Record = {}; for (const m of [ "select", "from", "where", "limit", "insert", "values", "returning", "update", "set", ]) { c[m] = vi.fn().mockReturnValue(c); } c.values = vi.fn().mockImplementation((v: unknown) => { onValues?.(v); return c; }); // `await db.select()...where(...)` (no limit) resolves via thenable. // biome-ignore lint/suspicious/noThenProperty: mimics Drizzle's thenable query builder c.then = (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) => Promise.resolve(terminal).then(res, rej); c.limit = vi.fn().mockReturnValue(Promise.resolve(terminal)); c.returning = vi.fn().mockReturnValue(Promise.resolve(terminal)); return c; } return { inserted, select: vi.fn().mockImplementation(() => chain(queue.shift() ?? [])), insert: vi.fn().mockImplementation(() => chain(insertRows, (v) => inserted.push(v))), update: vi.fn().mockImplementation(() => chain([])), }; } const USER = { id: "user-1" }; const FULL = { id: "plan-full", name: "Full Paket", brandCount: 0, isActive: true }; const ONE = { id: "plan-1", name: "1 Marka", brandCount: 1, isActive: true }; const BRANDS = [{ id: "b1" }, { id: "b2" }, { id: "b3" }]; const base = { userId: "user-1", reason: "manuel tanım", founderId: "founder-1" } as const; function svc(db: unknown) { return new BillingService(db as any, {} as any); } describe("BillingService.startSubscription", () => { it("creates an active Full-plan subscription with every active brand and the given days", async () => { const created = { id: "sub-new", startDate: new Date("2026-09-19T00:00:00Z"), endDate: new Date("2029-09-19T00:00:00Z"), }; const db = createMockDb([[USER], [], [FULL], BRANDS], [created]); const res = await svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 1095, }); expect(res.success).toBe(true); expect(res.subscriptionId).toBe("sub-new"); expect(res.brandCount).toBe(3); // 1st insert = subscription row, 2nd = brand rows expect(db.inserted).toHaveLength(2); const subRow = db.inserted[0] as { status: string; billingPeriod: string; startDate: Date; endDate: Date; }; expect(subRow.status).toBe("active"); expect(subRow.billingPeriod).toBe("yearly"); const diffDays = Math.round( (subRow.endDate.getTime() - subRow.startDate.getTime()) / 86_400_000, ); expect(diffDays).toBe(1095); expect(db.inserted[1]).toEqual( BRANDS.map((b) => ({ userId: "user-1", subscriptionId: "sub-new", brandId: b.id })), ); }); it("defaults the period from billingPeriod when days is omitted", async () => { const db = createMockDb([[USER], [], [FULL], BRANDS], [{ id: "sub-new" }]); await svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "monthly" }); const subRow = db.inserted[0] as { startDate: Date; endDate: Date }; const expected = new Date(subRow.startDate); expected.setMonth(expected.getMonth() + 1); expect(subRow.endDate.getTime()).toBe(expected.getTime()); }); it("uses the requested brands for a brand-limited plan", async () => { const db = createMockDb([[USER], [], [ONE], BRANDS], [{ id: "sub-new" }]); const res = await svc(db).startSubscription({ ...base, planId: ONE.id, billingPeriod: "monthly", brandIds: ["b2"], }); expect(res.brandCount).toBe(1); expect(db.inserted[1]).toEqual([ { userId: "user-1", subscriptionId: "sub-new", brandId: "b2" }, ]); }); it("rejects wrong brand count / unknown brands for a brand-limited plan", async () => { await expect( svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({ ...base, planId: ONE.id, billingPeriod: "monthly", brandIds: [], }), ).rejects.toBeInstanceOf(BadRequestException); await expect( svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({ ...base, planId: ONE.id, billingPeriod: "monthly", brandIds: ["nope"], }), ).rejects.toBeInstanceOf(BadRequestException); }); it("refuses when the user already has an active or trial subscription", async () => { const db = createMockDb([[USER], [{ id: "sub-live", status: "trial" }]]); await expect( svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly" }), ).rejects.toBeInstanceOf(ConflictException); expect(db.insert).not.toHaveBeenCalled(); }); it("404s on unknown user / plan and rejects inactive plans", async () => { await expect( svc(createMockDb([[]])).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", }), ).rejects.toBeInstanceOf(NotFoundException); await expect( svc(createMockDb([[USER], [], []])).startSubscription({ ...base, planId: "missing", billingPeriod: "yearly", }), ).rejects.toBeInstanceOf(NotFoundException); await expect( svc(createMockDb([[USER], [], [{ ...FULL, isActive: false }]])).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", }), ).rejects.toBeInstanceOf(ConflictException); }); it("validates days range and billingPeriod before touching the db", async () => { const db = createMockDb([]); await expect( svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 0 }), ).rejects.toBeInstanceOf(BadRequestException); await expect( svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 3651 }), ).rejects.toBeInstanceOf(BadRequestException); await expect( svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "weekly" as any }), ).rejects.toBeInstanceOf(BadRequestException); expect(db.select).not.toHaveBeenCalled(); }); });