408 lines
11 KiB
TypeScript
408 lines
11 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
|
import type { PrismaClient } from "@/generated/prisma/client"
|
|
import { createTestCategory, createTestUser } from "../helpers/factories"
|
|
import {
|
|
resetIntegrationTestDatabase,
|
|
startIntegrationTestDatabase,
|
|
stopIntegrationTestDatabase,
|
|
} from "../helpers/test-db"
|
|
|
|
let prisma: PrismaClient
|
|
let createItemUseCase: typeof import("@/use-cases/item.use-cases").createItemUseCase
|
|
let deleteItemUseCase: typeof import("@/use-cases/item.use-cases").deleteItemUseCase
|
|
let updateItemUseCase: typeof import("@/use-cases/item.use-cases").updateItemUseCase
|
|
|
|
beforeAll(async () => {
|
|
await startIntegrationTestDatabase()
|
|
|
|
const prismaModule = await import("@/lib/prisma")
|
|
const itemUseCases = await import("@/use-cases/item.use-cases")
|
|
|
|
prisma = prismaModule.prisma
|
|
createItemUseCase = itemUseCases.createItemUseCase
|
|
deleteItemUseCase = itemUseCases.deleteItemUseCase
|
|
updateItemUseCase = itemUseCases.updateItemUseCase
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
await resetIntegrationTestDatabase(prisma)
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await prisma?.$disconnect()
|
|
await stopIntegrationTestDatabase()
|
|
})
|
|
|
|
describe("item use-cases", () => {
|
|
it("creates an item with operational fields and records an IN movement", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
const result = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Laptop",
|
|
categoryId: category.id,
|
|
stock: 3,
|
|
trackingType: "QUANTITY",
|
|
status: "ACTIVE",
|
|
minStock: 1,
|
|
targetStock: 6,
|
|
})
|
|
|
|
expect(result).toEqual({ success: true })
|
|
|
|
const item = await prisma.item.findUnique({
|
|
where: { sku: "LAPTOP" },
|
|
include: { stockMovementLines: { include: { movement: true } } },
|
|
})
|
|
|
|
expect(item).toMatchObject({
|
|
name: "Laptop",
|
|
categoryId: category.id,
|
|
stock: 3,
|
|
trackingType: "QUANTITY",
|
|
status: "ACTIVE",
|
|
minStock: 1,
|
|
targetStock: 6,
|
|
deletedAt: null,
|
|
})
|
|
expect(item?.stockMovementLines).toHaveLength(1)
|
|
expect(item?.stockMovementLines[0]).toMatchObject({
|
|
stockDelta: 3,
|
|
})
|
|
expect(item?.stockMovementLines[0]?.movement).toMatchObject({
|
|
type: "RECEIPT",
|
|
performedById: actor.id,
|
|
})
|
|
})
|
|
|
|
it("generates unique skus for different names with the same normalized base", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Item A!",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
})
|
|
|
|
const secondCreate = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Item A?",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
})
|
|
|
|
expect(secondCreate).toEqual({ success: true })
|
|
|
|
const items = await prisma.item.findMany({
|
|
where: { categoryId: category.id },
|
|
orderBy: { sku: "asc" },
|
|
select: { sku: true, name: true },
|
|
})
|
|
|
|
expect(items).toEqual([
|
|
{ sku: "ITEM-A", name: "Item A!" },
|
|
{ sku: "ITEM-A-2", name: "Item A?" },
|
|
])
|
|
})
|
|
|
|
it("updates operational item fields without changing the sku", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
const createResult = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Monitor",
|
|
categoryId: category.id,
|
|
stock: 1,
|
|
})
|
|
|
|
expect(createResult).toEqual({ success: true })
|
|
|
|
const item = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "MONITOR" },
|
|
})
|
|
|
|
const updateResult = await updateItemUseCase({
|
|
actorId: actor.id,
|
|
id: item.id,
|
|
name: "Monitor",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
trackingType: "SERIALIZED",
|
|
status: "DISCONTINUED",
|
|
minStock: 2,
|
|
targetStock: 8,
|
|
})
|
|
|
|
expect(updateResult).toEqual({ success: true })
|
|
|
|
const updatedItem = await prisma.item.findUniqueOrThrow({
|
|
where: { id: item.id },
|
|
})
|
|
|
|
expect(updatedItem).toMatchObject({
|
|
sku: "MONITOR",
|
|
stock: 0,
|
|
trackingType: "SERIALIZED",
|
|
status: "DISCONTINUED",
|
|
minStock: 2,
|
|
targetStock: 8,
|
|
})
|
|
|
|
const stockMovements = await prisma.inventoryMovement.findMany({
|
|
where: { stockLines: { some: { itemId: item.id } } },
|
|
})
|
|
|
|
expect(stockMovements).toHaveLength(1)
|
|
})
|
|
|
|
it("rejects duplicate item names", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Monitor",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
})
|
|
|
|
const duplicate = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Monitor",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
})
|
|
|
|
expect(duplicate).toEqual({
|
|
success: false,
|
|
errors: {
|
|
name: ["An item with this name already exists"],
|
|
},
|
|
})
|
|
})
|
|
|
|
it("writes an ISSUE movement when a QUANTITY item stock decreases to zero", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
const created = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Cable",
|
|
categoryId: category.id,
|
|
stock: 1,
|
|
})
|
|
|
|
expect(created).toEqual({ success: true })
|
|
|
|
const item = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "CABLE" },
|
|
})
|
|
|
|
const updated = await updateItemUseCase({
|
|
actorId: actor.id,
|
|
id: item.id,
|
|
name: "Cable",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
})
|
|
|
|
expect(updated).toEqual({ success: true })
|
|
|
|
const refreshedItem = await prisma.item.findUniqueOrThrow({
|
|
where: { id: item.id },
|
|
})
|
|
|
|
expect(refreshedItem.stock).toBe(0)
|
|
|
|
const movements = await prisma.inventoryMovement.findMany({
|
|
where: { stockLines: { some: { itemId: item.id } } },
|
|
include: { stockLines: true },
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
})
|
|
|
|
expect(movements).toHaveLength(2)
|
|
expect(movements[1]).toMatchObject({
|
|
type: "ISSUE",
|
|
performedById: actor.id,
|
|
})
|
|
expect(movements[1].stockLines[0]).toMatchObject({
|
|
itemId: item.id,
|
|
stockDelta: -1,
|
|
previousStock: 1,
|
|
newStock: 0,
|
|
})
|
|
})
|
|
|
|
it("writes an ADJUSTMENT movement with INVENTORY_CORRECTION reason when QUANTITY item decreases with reason", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
const created = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Mouse",
|
|
categoryId: category.id,
|
|
stock: 5,
|
|
})
|
|
|
|
expect(created).toEqual({ success: true })
|
|
|
|
const item = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "MOUSE" },
|
|
})
|
|
|
|
const updated = await updateItemUseCase({
|
|
actorId: actor.id,
|
|
id: item.id,
|
|
name: "Mouse",
|
|
categoryId: category.id,
|
|
stock: 4,
|
|
reason: "INVENTORY_CORRECTION",
|
|
})
|
|
|
|
expect(updated).toEqual({ success: true })
|
|
|
|
const refreshedItem = await prisma.item.findUniqueOrThrow({
|
|
where: { id: item.id },
|
|
})
|
|
|
|
expect(refreshedItem.stock).toBe(4)
|
|
|
|
const movements = await prisma.inventoryMovement.findMany({
|
|
where: { stockLines: { some: { itemId: item.id } } },
|
|
include: { stockLines: true },
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
})
|
|
|
|
expect(movements).toHaveLength(2)
|
|
expect(movements[1]).toMatchObject({
|
|
type: "ADJUSTMENT",
|
|
reason: "INVENTORY_CORRECTION",
|
|
performedById: actor.id,
|
|
})
|
|
expect(movements[1].stockLines[0]).toMatchObject({
|
|
itemId: item.id,
|
|
stockDelta: -1,
|
|
previousStock: 5,
|
|
newStock: 4,
|
|
})
|
|
})
|
|
|
|
it("creates a SERIALIZED item with stock value and persists stock as 0 with no movement", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
const result = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Phone",
|
|
categoryId: category.id,
|
|
stock: 3,
|
|
trackingType: "SERIALIZED",
|
|
})
|
|
|
|
expect(result).toEqual({ success: true })
|
|
|
|
const item = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "PHONE" },
|
|
})
|
|
|
|
expect(item).toMatchObject({
|
|
stock: 0,
|
|
trackingType: "SERIALIZED",
|
|
})
|
|
|
|
const movements = await prisma.inventoryMovement.findMany({
|
|
where: { stockLines: { some: { itemId: item.id } } },
|
|
})
|
|
|
|
expect(movements).toHaveLength(0)
|
|
})
|
|
|
|
it("updates a SERIALIZED item with stock value and leaves stock unchanged with no movement", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
const created = await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Tablet",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
trackingType: "SERIALIZED",
|
|
})
|
|
|
|
expect(created).toEqual({ success: true })
|
|
|
|
const item = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "TABLET" },
|
|
})
|
|
|
|
const updated = await updateItemUseCase({
|
|
actorId: actor.id,
|
|
id: item.id,
|
|
name: "Tablet",
|
|
categoryId: category.id,
|
|
stock: 5,
|
|
trackingType: "SERIALIZED",
|
|
})
|
|
|
|
expect(updated).toEqual({ success: true })
|
|
|
|
const refreshedItem = await prisma.item.findUniqueOrThrow({
|
|
where: { id: item.id },
|
|
})
|
|
|
|
expect(refreshedItem.stock).toBe(0)
|
|
|
|
const movements = await prisma.inventoryMovement.findMany({
|
|
where: { stockLines: { some: { itemId: item.id } } },
|
|
})
|
|
|
|
expect(movements).toHaveLength(0)
|
|
})
|
|
|
|
it("blocks deleting items with stock and soft deletes empty items", async () => {
|
|
const actor = await createTestUser(prisma)
|
|
const category = await createTestCategory(prisma)
|
|
|
|
await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Keyboard",
|
|
categoryId: category.id,
|
|
stock: 2,
|
|
})
|
|
|
|
const stockedItem = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "KEYBOARD" },
|
|
})
|
|
|
|
await expect(deleteItemUseCase(stockedItem.id)).resolves.toEqual({
|
|
success: false,
|
|
errors: { id: ["Item has stock, you cannot delete it"] },
|
|
})
|
|
|
|
await createItemUseCase({
|
|
actorId: actor.id,
|
|
name: "Mouse",
|
|
categoryId: category.id,
|
|
stock: 0,
|
|
})
|
|
|
|
const emptyItem = await prisma.item.findUniqueOrThrow({
|
|
where: { sku: "MOUSE" },
|
|
})
|
|
|
|
await expect(deleteItemUseCase(emptyItem.id)).resolves.toEqual({
|
|
success: true,
|
|
})
|
|
|
|
const deletedItem = await prisma.item.findUniqueOrThrow({
|
|
where: { id: emptyItem.id },
|
|
})
|
|
|
|
expect(deletedItem.deletedAt).toBeInstanceOf(Date)
|
|
})
|
|
})
|