From 0f733627bde516173e259f88557073399753451f Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 13:59:51 +0800 Subject: [PATCH] =?UTF-8?q?test(04-02):=20asset=20service=20tests=20?= =?UTF-8?q?=E2=80=94=2011=20cases=20covering=20full=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assign to subscriber/technician creates correct ISSUED movements - Return from subscriber/technician creates RETURNED with condition - Rejects reassignment without return, rejects batch items - Admin disposal creates write-off JE (DR 5030, CR 1200) - Non-admin disposal rejected, field disposal rejected - History returns chronological timeline with resolved names - Full lifecycle: RECEIVED->ISSUED->RETURNED->ISSUED->RETURNED->DISPOSED Co-Authored-By: Claude Opus 4.6 --- src/lib/__tests__/asset-service.test.ts | 557 ++++++++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 src/lib/__tests__/asset-service.test.ts diff --git a/src/lib/__tests__/asset-service.test.ts b/src/lib/__tests__/asset-service.test.ts new file mode 100644 index 0000000..bf03a86 --- /dev/null +++ b/src/lib/__tests__/asset-service.test.ts @@ -0,0 +1,557 @@ +/** + * Asset Service Integration Tests + * + * Tests the full asset lifecycle: + * 1. Assign item to subscriber — creates ISSUED movement with SUBSCRIBER location + * 2. Assign item to technician — creates ISSUED movement with TECHNICIAN location + * 3. Return item from subscriber — creates RETURNED movement back to WAREHOUSE with condition + * 4. Return item from technician — creates RETURNED movement back to WAREHOUSE + * 5. Reject assignment of item already with a subscriber (must return first) + * 6. Reject assignment of batch item to subscriber (only SERIALIZED allowed) + * 7. Dispose item — ADMIN creates DISPOSED movement + write-off JE (DR 5030, CR 1200) + * 8. Reject disposal by non-admin (OFFICE_STAFF gets authorization error) + * 9. Reject disposal of item not in warehouse + * 10. Get asset history — returns chronological timeline with resolved location names + * 11. Full lifecycle: RECEIVED -> ISSUED to tech -> RETURNED -> ISSUED to sub -> RETURNED -> DISPOSED + * + * CLEANUP ORDER: + * stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> + * journalEntries -> subscribers -> servicePlans -> accountingPeriods -> accounts -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { InventoryService } from "@/lib/services/inventory-service"; +import { AssetService } from "@/lib/services/asset-service"; +import { Prisma, Role, TenantStatus } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantId: string; +let adminUserId: string; +let officeStaffUserId: string; +let technicianUserId: string; +let subscriberId: string; +let servicePlanId: string; + +// Account IDs for JE verification +let equipExpenseAccountId: string; // 5030 +let equipInventoryAccountId: string; // 1200 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tp() { + return withTenantContext(tenantId); +} + +/** Create a serialized item and receive it into the warehouse */ +async function createAndReceiveItem(suffix: string, cost = 150) { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `Asset Test Item ${suffix}`, + itemType: "ONU", + model: "HG8145V5", + serialNumber: `SN-ASSET-${TS}-${suffix}`, + trackingType: "SERIALIZED", + purchaseCost: cost, + }); + + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + condition: "NEW", + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: adminUserId, + }); + + return item; +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create tenant + const tenant = await prisma.tenant.create({ + data: { + name: `Asset Test Tenant ${TS}`, + slug: `asset-test-${TS}`, + ownerEmail: `asset-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantId = tenant.id; + + // Seed COA + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantId); + }); + + // Look up account IDs + const accounts = await prisma.account.findMany({ + where: { tenantId, code: { in: ["5030", "1200"] } }, + select: { id: true, code: true }, + }); + const accountMap = new Map(accounts.map((a) => [a.code, a.id])); + equipExpenseAccountId = accountMap.get("5030")!; + equipInventoryAccountId = accountMap.get("1200")!; + + expect(equipExpenseAccountId).toBeDefined(); + expect(equipInventoryAccountId).toBeDefined(); + + // Create admin user + const admin = await prisma.user.create({ + data: { + email: `asset-admin-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Asset", + lastName: "Admin", + tenantId, + roles: [Role.ADMIN], + isActive: true, + }, + }); + adminUserId = admin.id; + + // Create office staff user + const staff = await prisma.user.create({ + data: { + email: `asset-staff-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Office", + lastName: "Staff", + tenantId, + roles: [Role.OFFICE_STAFF], + isActive: true, + }, + }); + officeStaffUserId = staff.id; + + // Create technician user + const tech = await prisma.user.create({ + data: { + email: `asset-tech-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Field", + lastName: "Tech", + tenantId, + roles: [Role.TECHNICIAN], + isActive: true, + }, + }); + technicianUserId = tech.id; + + // Create service plan (needed for subscriber) + const plan = await prisma.servicePlan.create({ + data: { + tenantId, + name: `Asset Test Plan ${TS}`, + speed: "50 Mbps", + monthlyPrice: new Prisma.Decimal(999), + billingType: "PREPAID", + }, + }); + servicePlanId = plan.id; + + // Create subscriber + const subscriber = await prisma.subscriber.create({ + data: { + tenantId, + accountNumber: `SUB-ASSET-${TS}`, + firstName: "John", + lastName: "Doe", + address: "123 Test St", + servicePlanId, + billingDay: 15, + }, + }); + subscriberId = subscriber.id; +}); + +afterAll(async () => { + // Cleanup in order + await prisma.stockMovement.deleteMany({ where: { tenantId } }); + await prisma.inventoryItem.deleteMany({ where: { tenantId } }); + await prisma.journalEntryLine.deleteMany({ where: { tenantId } }); + await prisma.journalEntry.updateMany({ + where: { tenantId, reversesEntryId: { not: null } }, + data: { reversesEntryId: null }, + }); + await prisma.journalEntry.deleteMany({ where: { tenantId } }); + await prisma.subscriber.deleteMany({ where: { tenantId } }); + await prisma.servicePlan.deleteMany({ where: { tenantId } }); + await prisma.accountingPeriod.deleteMany({ where: { tenantId } }); + await prisma.account.deleteMany({ where: { tenantId } }); + await prisma.user.deleteMany({ where: { tenantId } }); + await prisma.tenant.deleteMany({ where: { id: tenantId } }); +}); + +// --------------------------------------------------------------------------- +// Tests: Assignment +// --------------------------------------------------------------------------- + +describe("assignToSubscriber", () => { + it("assigns item to subscriber — creates ISSUED movement with SUBSCRIBER location", async () => { + const item = await createAndReceiveItem("sub-assign"); + + const movement = await AssetService.assignToSubscriber(tp(), tenantId, { + itemId: item.id, + subscriberId, + condition: "NEW", + performedById: adminUserId, + notes: "Installation at customer site", + }); + + expect(movement.movementType).toBe("ISSUED"); + expect(movement.fromLocationType).toBe("WAREHOUSE"); + expect(movement.toLocationType).toBe("SUBSCRIBER"); + expect(movement.toLocationId).toBe(subscriberId); + expect(movement.condition).toBe("NEW"); + expect(movement.notes).toBe("Installation at customer site"); + }); +}); + +describe("assignToTechnician", () => { + it("assigns item to technician — creates ISSUED movement with TECHNICIAN location", async () => { + const item = await createAndReceiveItem("tech-assign"); + + const movement = await AssetService.assignToTechnician(tp(), tenantId, { + itemId: item.id, + technicianUserId, + condition: "NEW", + performedById: adminUserId, + }); + + expect(movement.movementType).toBe("ISSUED"); + expect(movement.fromLocationType).toBe("WAREHOUSE"); + expect(movement.toLocationType).toBe("TECHNICIAN"); + expect(movement.toLocationId).toBe(technicianUserId); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Return +// --------------------------------------------------------------------------- + +describe("returnAsset", () => { + it("returns item from subscriber — creates RETURNED movement to WAREHOUSE with condition", async () => { + const item = await createAndReceiveItem("sub-return"); + + // Assign to subscriber first + await AssetService.assignToSubscriber(tp(), tenantId, { + itemId: item.id, + subscriberId, + condition: "NEW", + performedById: adminUserId, + }); + + // Return it + const movement = await AssetService.returnAsset(tp(), tenantId, { + itemId: item.id, + condition: "USED", + performedById: adminUserId, + notes: "Retrieved during service call", + }); + + expect(movement.movementType).toBe("RETURNED"); + expect(movement.fromLocationType).toBe("SUBSCRIBER"); + expect(movement.fromLocationId).toBe(subscriberId); + expect(movement.toLocationType).toBe("WAREHOUSE"); + expect(movement.condition).toBe("USED"); + }); + + it("returns item from technician — creates RETURNED movement to WAREHOUSE", async () => { + const item = await createAndReceiveItem("tech-return"); + + // Assign to technician first + await AssetService.assignToTechnician(tp(), tenantId, { + itemId: item.id, + technicianUserId, + condition: "NEW", + performedById: adminUserId, + }); + + // Return it + const movement = await AssetService.returnAsset(tp(), tenantId, { + itemId: item.id, + condition: "USED", + performedById: technicianUserId, + }); + + expect(movement.movementType).toBe("RETURNED"); + expect(movement.fromLocationType).toBe("TECHNICIAN"); + expect(movement.toLocationType).toBe("WAREHOUSE"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Rejection cases +// --------------------------------------------------------------------------- + +describe("assignment rejections", () => { + it("rejects assignment of item already with a subscriber (must return first)", async () => { + const item = await createAndReceiveItem("already-assigned"); + + // Assign to subscriber + await AssetService.assignToSubscriber(tp(), tenantId, { + itemId: item.id, + subscriberId, + condition: "NEW", + performedById: adminUserId, + }); + + // Try to assign to subscriber again + await expect( + AssetService.assignToSubscriber(tp(), tenantId, { + itemId: item.id, + subscriberId, + condition: "NEW", + performedById: adminUserId, + }) + ).rejects.toThrow(/already assigned to a subscriber/i); + }); + + it("rejects assignment of batch item to subscriber (only SERIALIZED allowed)", async () => { + const batchItem = await InventoryService.registerItem(tp(), tenantId, { + name: `Batch Cable ${TS}`, + itemType: "Cable", + trackingType: "BATCH", + purchaseCost: 5, + }); + + // Receive batch items + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: batchItem.id, + movementType: "RECEIVED", + quantity: 10, + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: adminUserId, + }); + + await expect( + AssetService.assignToSubscriber(tp(), tenantId, { + itemId: batchItem.id, + subscriberId, + condition: "NEW", + performedById: adminUserId, + }) + ).rejects.toThrow(/SERIALIZED/i); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Disposal +// --------------------------------------------------------------------------- + +describe("disposeAsset", () => { + it("ADMIN disposes item — creates DISPOSED movement + write-off JE (DR 5030, CR 1200)", async () => { + const item = await createAndReceiveItem("dispose-admin", 200); + + const result = await AssetService.disposeAsset(tp(), tenantId, { + itemId: item.id, + performedById: adminUserId, + notes: "Water damage beyond repair", + userRoles: [Role.ADMIN], + }); + + // Verify movement + expect(result.movement.movementType).toBe("DISPOSED"); + expect(result.movement.fromLocationType).toBe("WAREHOUSE"); + expect(result.movement.toLocationType).toBeNull(); + + // Verify JE was created + expect(result.journalEntryId).not.toBeNull(); + + const je = await prisma.journalEntry.findUnique({ + where: { id: result.journalEntryId! }, + include: { lines: true }, + }); + + expect(je).not.toBeNull(); + expect(je!.status).toBe("POSTED"); + expect(je!.description).toContain("Disposal write-off"); + expect(je!.description).toContain(item.serialNumber); + + // DR 5030 Equipment Expense + const debitLine = je!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0)); + expect(debitLine?.accountId).toBe(equipExpenseAccountId); + expect(debitLine?.debit.toString()).toBe("200"); + + // CR 1200 Equipment Inventory + const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0)); + expect(creditLine?.accountId).toBe(equipInventoryAccountId); + expect(creditLine?.credit.toString()).toBe("200"); + }); + + it("rejects disposal by non-admin (OFFICE_STAFF gets authorization error)", async () => { + const item = await createAndReceiveItem("dispose-staff"); + + await expect( + AssetService.disposeAsset(tp(), tenantId, { + itemId: item.id, + performedById: officeStaffUserId, + userRoles: [Role.OFFICE_STAFF], + }) + ).rejects.toThrow(/Only ADMIN/i); + }); + + it("rejects disposal of item not in warehouse", async () => { + const item = await createAndReceiveItem("dispose-field"); + + // Assign to technician (out of warehouse) + await AssetService.assignToTechnician(tp(), tenantId, { + itemId: item.id, + technicianUserId, + condition: "NEW", + performedById: adminUserId, + }); + + await expect( + AssetService.disposeAsset(tp(), tenantId, { + itemId: item.id, + performedById: adminUserId, + userRoles: [Role.ADMIN], + }) + ).rejects.toThrow(/WAREHOUSE/i); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: History +// --------------------------------------------------------------------------- + +describe("getAssetHistory", () => { + it("returns chronological timeline with resolved location names", async () => { + const item = await createAndReceiveItem("history"); + + // Assign to subscriber + await AssetService.assignToSubscriber(tp(), tenantId, { + itemId: item.id, + subscriberId, + condition: "NEW", + performedById: adminUserId, + }); + + // Return + await AssetService.returnAsset(tp(), tenantId, { + itemId: item.id, + condition: "USED", + performedById: adminUserId, + }); + + const history = await AssetService.getAssetHistory(tp(), item.id); + + expect(history).toHaveLength(3); // RECEIVED, ISSUED, RETURNED + + // First entry: RECEIVED + expect(history[0].movementType).toBe("RECEIVED"); + expect(history[0].fromLocation).toBeNull(); + expect(history[0].toLocation?.type).toBe("WAREHOUSE"); + expect(history[0].toLocation?.name).toBe("Warehouse"); + + // Second entry: ISSUED to subscriber + expect(history[1].movementType).toBe("ISSUED"); + expect(history[1].fromLocation?.type).toBe("WAREHOUSE"); + expect(history[1].toLocation?.type).toBe("SUBSCRIBER"); + expect(history[1].toLocation?.name).toBe("John Doe"); + + // Third entry: RETURNED + expect(history[2].movementType).toBe("RETURNED"); + expect(history[2].fromLocation?.type).toBe("SUBSCRIBER"); + expect(history[2].toLocation?.type).toBe("WAREHOUSE"); + expect(history[2].condition).toBe("USED"); + + // Verify performedBy is resolved + expect(history[0].performedBy).toBe("Asset Admin"); + + // Verify chronological order + for (let i = 1; i < history.length; i++) { + expect(new Date(history[i].date).getTime()).toBeGreaterThanOrEqual( + new Date(history[i - 1].date).getTime() + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Full Lifecycle +// --------------------------------------------------------------------------- + +describe("full asset lifecycle", () => { + it("RECEIVED -> ISSUED to tech -> RETURNED -> ISSUED to sub -> RETURNED -> DISPOSED", async () => { + const item = await createAndReceiveItem("lifecycle", 300); + + // Step 1: Already received in createAndReceiveItem + + // Step 2: Issue to technician + await AssetService.assignToTechnician(tp(), tenantId, { + itemId: item.id, + technicianUserId, + condition: "NEW", + performedById: adminUserId, + }); + + // Step 3: Return from technician + await AssetService.returnAsset(tp(), tenantId, { + itemId: item.id, + condition: "USED", + performedById: technicianUserId, + }); + + // Step 4: Issue to subscriber + await AssetService.assignToSubscriber(tp(), tenantId, { + itemId: item.id, + subscriberId, + condition: "USED", + performedById: adminUserId, + }); + + // Step 5: Return from subscriber + await AssetService.returnAsset(tp(), tenantId, { + itemId: item.id, + condition: "DAMAGED", + performedById: adminUserId, + }); + + // Step 6: Dispose + const result = await AssetService.disposeAsset(tp(), tenantId, { + itemId: item.id, + performedById: adminUserId, + notes: "End of life", + userRoles: [Role.ADMIN], + }); + + expect(result.movement.movementType).toBe("DISPOSED"); + expect(result.journalEntryId).not.toBeNull(); + + // Verify full history shows all 6 entries + const history = await AssetService.getAssetHistory(tp(), item.id); + expect(history).toHaveLength(6); + + expect(history[0].movementType).toBe("RECEIVED"); + expect(history[1].movementType).toBe("ISSUED"); + expect(history[1].toLocation?.type).toBe("TECHNICIAN"); + expect(history[1].toLocation?.name).toBe("Field Tech"); + expect(history[2].movementType).toBe("RETURNED"); + expect(history[3].movementType).toBe("ISSUED"); + expect(history[3].toLocation?.type).toBe("SUBSCRIBER"); + expect(history[3].toLocation?.name).toBe("John Doe"); + expect(history[4].movementType).toBe("RETURNED"); + expect(history[4].condition).toBe("DAMAGED"); + expect(history[5].movementType).toBe("DISPOSED"); + + // Verify disposed item has no current location + const location = await AssetService.getCurrentLocation(tp(), item.id); + expect(location).toBeNull(); + }); +});