feat(assets): add asset metadata views and enforce assignment transitions

This commit is contained in:
2026-06-19 17:14:22 +02:00
parent c1763ed007
commit f32d55a7b0
17 changed files with 1573 additions and 70 deletions
@@ -14,16 +14,19 @@ import {
let prisma: PrismaClient
let createAssetUseCase: typeof import("@/use-cases/asset.use-cases").createAssetUseCase
let updateAssetUseCase: typeof import("@/use-cases/asset.use-cases").updateAssetUseCase
let AssetService: typeof import("@/services/asset.service").AssetService
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
const assetUseCases = await import("@/use-cases/asset.use-cases")
const assetService = await import("@/services/asset.service")
prisma = prismaModule.prisma
createAssetUseCase = assetUseCases.createAssetUseCase
updateAssetUseCase = assetUseCases.updateAssetUseCase
AssetService = assetService.AssetService
})
beforeEach(async () => {
@@ -74,8 +77,110 @@ describe("asset use-cases", () => {
type: "RECEIPT",
performedById: actor.id,
})
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId })
expect(movements[0].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: 1,
})
expect(movements[0].assetLines[0]).toMatchObject({
assetId: result.assetId,
})
})
it("persists operational asset fields during create and update flows", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const created = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-OPS-001",
status: "AVAILABLE",
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
purchaseDate: new Date("2026-01-15T00:00:00.000Z"),
purchasePrice: 1249.99,
warrantyEndsAt: new Date("2028-01-15T00:00:00.000Z"),
})
expect(created.success).toBe(true)
if (!created.success) throw new Error("Expected asset creation success")
const createdAsset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(createdAsset).toMatchObject({
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
})
expect(createdAsset.purchaseDate?.toISOString()).toBe(
"2026-01-15T00:00:00.000Z",
)
expect(createdAsset.purchasePrice?.toString()).toBe("1249.99")
expect(createdAsset.warrantyEndsAt?.toISOString()).toBe(
"2028-01-15T00:00:00.000Z",
)
const updated = await updateAssetUseCase({
actorId: actor.id,
id: created.assetId,
itemId: item.id,
serialNumber: "ASSET-OPS-001",
status: "BROKEN",
assetTag: "IT-000778",
manufacturer: "Dell",
model: "Latitude 7420",
purchaseDate: new Date("2026-02-01T00:00:00.000Z"),
purchasePrice: 1499.5,
warrantyEndsAt: new Date("2027-02-01T00:00:00.000Z"),
})
expect(updated.success).toBe(true)
const updatedAsset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(updatedAsset).toMatchObject({
assetTag: "IT-000778",
manufacturer: "Dell",
model: "Latitude 7420",
status: "BROKEN",
})
expect(updatedAsset.purchaseDate?.toISOString()).toBe(
"2026-02-01T00:00:00.000Z",
)
expect(updatedAsset.purchasePrice?.toString()).toBe("1499.5")
expect(updatedAsset.warrantyEndsAt?.toISOString()).toBe(
"2027-02-01T00:00:00.000Z",
)
})
it("soft deletes assets and excludes them from active queries", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const created = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-SOFT-DELETE-001",
status: "AVAILABLE",
})
expect(created.success).toBe(true)
if (!created.success) throw new Error("Expected asset creation success")
await AssetService.delete(created.assetId)
const deletedAsset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(deletedAsset.deletedAt).toBeInstanceOf(Date)
expect(await AssetService.findById(created.assetId)).toBeNull()
expect(await AssetService.findAllAssetsCount()).toBe(0)
expect(await AssetService.findAll()).toHaveLength(0)
})
it("creates an assigned asset with assignment and ASSIGNMENT movement", async () => {
@@ -128,8 +233,31 @@ describe("asset use-cases", () => {
assignmentId: assignment.id,
performedById: actor.id,
})
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 })
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId })
expect(movements[0].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: -1,
})
expect(movements[0].assetLines[0]).toMatchObject({
assetId: result.assetId,
})
})
it("rejects creating an assigned asset without a person", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const result = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-ASSIGNED-NO-PERSON-001",
status: "ASSIGNED",
})
expect(result).toEqual({
success: false,
errors: { personId: ["Person is required"] },
})
await expect(prisma.asset.count()).resolves.toBe(0)
})
it("moves an available asset to assigned and back to available", async () => {
@@ -175,7 +303,9 @@ describe("asset use-cases", () => {
expect(activeAssignment).toMatchObject({
personId: person.id,
})
expect(activeAssignment.assetLines[0]).toMatchObject({ assetId: created.assetId })
expect(activeAssignment.assetLines[0]).toMatchObject({
assetId: created.assetId,
})
await expect(
updateAssetUseCase({
@@ -217,14 +347,57 @@ describe("asset use-cases", () => {
assignmentId: activeAssignment.id,
performedById: actor.id,
})
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 })
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId })
expect(movements[1].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: -1,
})
expect(movements[1].assetLines[0]).toMatchObject({
assetId: created.assetId,
})
expect(movements[2]).toMatchObject({
assignmentId: activeAssignment.id,
performedById: actor.id,
})
expect(movements[2].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
expect(movements[2].assetLines[0]).toMatchObject({ assetId: created.assetId })
expect(movements[2].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: 1,
})
expect(movements[2].assetLines[0]).toMatchObject({
assetId: created.assetId,
})
})
it("rejects updating an asset to assigned without a person", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const created = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-UPDATE-ASSIGNED-NO-PERSON-001",
status: "AVAILABLE",
})
expect(created.success).toBe(true)
if (!created.success) throw new Error("Expected asset creation success")
const result = await updateAssetUseCase({
actorId: actor.id,
id: created.assetId,
itemId: item.id,
serialNumber: "ASSET-UPDATE-ASSIGNED-NO-PERSON-001",
status: "ASSIGNED",
})
expect(result).toEqual({
success: false,
errors: { personId: ["Person is required"] },
})
const asset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(asset.status).toBe("AVAILABLE")
})
it("returns an active assignment without restoring stock when an assigned asset moves to a terminal status", async () => {
@@ -286,7 +459,12 @@ describe("asset use-cases", () => {
assignmentId: activeAssignment.id,
performedById: actor.id,
})
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId })
expect(movements[1].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: 1,
})
expect(movements[1].assetLines[0]).toMatchObject({
assetId: created.assetId,
})
})
})