test(04-02): asset service tests — 11 cases covering full lifecycle

- 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 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 13:59:51 +08:00
parent eb29d6e3be
commit 0f733627bd

View File

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