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:
557
src/lib/__tests__/asset-service.test.ts
Normal file
557
src/lib/__tests__/asset-service.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user