feat: implement component-item management with CRUD operations and status updates

This commit is contained in:
Tran Anh Tuan
2026-05-11 17:49:18 +07:00
parent 9ea72b4eea
commit 0ff65a18c0
23 changed files with 3870 additions and 0 deletions

View File

@@ -0,0 +1,518 @@
package repositories
import (
"context"
"fmt"
"os"
"testing"
"time"
"wm-backend/internal/models"
db "wm-backend/sqlc_gen"
"github.com/jackc/pgx/v5/pgxpool"
)
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// testDB returns a connection pool for tests. Set WM_TEST_DB_URL to override
// the default connection string. If the DB is not available the test is skipped.
func testDB(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("WM_TEST_DB_URL")
if dsn == "" {
dsn = "postgres://root:Smatec2026@localhost:5432/warehouse_management?sslmode=disable"
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("skipping integration test: cannot connect to DB: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("skipping integration test: cannot ping DB: %v", err)
}
return pool
}
// seedComponentItem inserts all required parent records and returns
// the created component_item ID, plus its component_id and container_id.
func seedComponentItem(t *testing.T, pool *pgxpool.Pool, q *db.Queries, quantity int32, status db.ComponentItemStatusEnum) (itemID, componentID, containerID int64) {
t.Helper()
ctx := context.Background()
// warehouse
w, err := q.CreateWarehouse(ctx, db.CreateWarehouseParams{
Name: fmt.Sprintf("test-wh-%d", time.Now().UnixNano()),
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed warehouse: %v", err)
}
// room
r, err := q.CreateRoom(ctx, db.CreateRoomParams{
WarehouseID: w.ID,
Name: fmt.Sprintf("test-room-%d", time.Now().UnixNano()),
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed room: %v", err)
}
// cabinet
cb, err := q.CreateCabinet(ctx, db.CreateCabinetParams{
RoomID: r.ID,
Name: fmt.Sprintf("test-cab-%d", time.Now().UnixNano()),
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed cabinet: %v", err)
}
// shelf
s, err := q.CreateShelve(ctx, db.CreateShelveParams{
CabinetID: cb.ID,
Name: fmt.Sprintf("test-shelf-%d", time.Now().UnixNano()),
LevelIndex: 1,
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed shelf: %v", err)
}
// container
cn, err := q.CreateContainer(ctx, db.CreateContainerParams{
ShelfID: s.ID,
Name: fmt.Sprintf("test-cont-%d", time.Now().UnixNano()),
ContainerType: db.ContainerTypeEnumOther,
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed container: %v", err)
}
containerID = cn.ID
// component_type
ct, err := q.CreateComponentType(ctx, db.CreateComponentTypeParams{
Name: fmt.Sprintf("test-ctype-%d", time.Now().UnixNano()),
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed component_type: %v", err)
}
// component
c, err := q.CreateComponent(ctx, db.CreateComponentParams{
ComponentTypeID: ct.ID,
Name: fmt.Sprintf("test-comp-%d", time.Now().UnixNano()),
Unit: "cái",
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed component: %v", err)
}
componentID = c.ID
// component_item
ci, err := q.CreateComponentItem(ctx, db.CreateComponentItemParams{
ComponentID: componentID,
ContainerID: containerID,
Quantity: quantity,
Status: status,
Metadata: []byte("{}"),
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("seed component_item: %v", err)
}
return ci.ID, componentID, containerID
}
// cleanupSeeded removes test data in reverse dependency order.
func cleanupSeeded(t *testing.T, pool *pgxpool.Pool, itemID, componentID, containerID int64) {
t.Helper()
ctx := context.Background()
q := db.New(pool)
// Delete history rows referencing this item
_, _ = pool.Exec(ctx, "DELETE FROM component_status_history WHERE component_item_id = $1", itemID)
// Delete component_items (including those possibly created by the test)
_, _ = pool.Exec(ctx, "DELETE FROM component_items WHERE component_id = $1", componentID)
_, _ = q.DeleteComponent(ctx, componentID)
// Delete component_type we need to find it from the component
_, _ = pool.Exec(ctx, "DELETE FROM component_types WHERE id = (SELECT component_type_id FROM components WHERE id = $1)", componentID)
_, _ = q.DeleteContainer(ctx, containerID)
// Delete container's hierarchy
_, _ = pool.Exec(ctx, "DELETE FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1)", containerID)
_, _ = pool.Exec(ctx, "DELETE FROM cabinets WHERE id IN (SELECT cabinet_id FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1))", containerID)
_, _ = pool.Exec(ctx, "DELETE FROM rooms WHERE id IN (SELECT room_id FROM cabinets WHERE id IN (SELECT cabinet_id FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1)))", containerID)
_, _ = pool.Exec(ctx, "DELETE FROM warehouses WHERE id IN (SELECT warehouse_id FROM rooms WHERE id IN (SELECT room_id FROM cabinets WHERE id IN (SELECT cabinet_id FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1))))", containerID)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestUpdateComponentItemStatus_Case1_ChangeAll_NilQuantity(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 20, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, nil, "test note", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify result
if result.StatusHistory.OldStatus != "normal" {
t.Errorf("expected old status 'normal', got '%s'", result.StatusHistory.OldStatus)
}
if result.StatusHistory.NewStatus != "damaged" {
t.Errorf("expected new status 'damaged', got '%s'", result.StatusHistory.NewStatus)
}
if result.StatusHistory.ChangedQuantity != 20 {
t.Errorf("expected changed quantity 20, got %d", result.StatusHistory.ChangedQuantity)
}
if result.StatusHistory.ChangedBy != "system" {
t.Errorf("expected changed_by 'system', got '%s'", result.StatusHistory.ChangedBy)
}
if result.NewComponentItemID != nil {
t.Error("expected NewComponentItemID to be nil for case 1")
}
if result.MergedComponentItemID != nil {
t.Error("expected MergedComponentItemID to be nil for case 1")
}
// Verify DB state
updated, err := q.GetComponentItemByID(context.Background(), itemID)
if err != nil {
t.Fatalf("get updated item: %v", err)
}
if updated.Status != db.ComponentItemStatusEnumDamaged {
t.Errorf("expected status 'damaged', got '%s'", updated.Status)
}
if updated.Quantity != 20 {
t.Errorf("expected quantity 20, got %d", updated.Quantity)
}
}
func TestUpdateComponentItemStatus_Case1_ChangeAll_ExplicitQuantity(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 15, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
changedQty := int32(15)
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.StatusHistory.ChangedQuantity != 15 {
t.Errorf("expected changed quantity 15, got %d", result.StatusHistory.ChangedQuantity)
}
}
func TestUpdateComponentItemStatus_Case2_Split_NoExistingTarget(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 20, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
changedQty := int32(5)
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "split test", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify result
if result.NewComponentItemID == nil {
t.Fatal("expected NewComponentItemID to be set for case 2")
}
if result.MergedComponentItemID != nil {
t.Error("expected MergedComponentItemID to be nil for case 2")
}
// Original record should have reduced quantity
orig, err := q.GetComponentItemByID(context.Background(), itemID)
if err != nil {
t.Fatalf("get original item: %v", err)
}
if orig.Quantity != 15 {
t.Errorf("expected original quantity 15, got %d", orig.Quantity)
}
if orig.Status != db.ComponentItemStatusEnumNormal {
t.Errorf("expected original status 'normal', got '%s'", orig.Status)
}
// New record should have the split quantity with target status
newItem, err := q.GetComponentItemByID(context.Background(), *result.NewComponentItemID)
if err != nil {
t.Fatalf("get new item: %v", err)
}
if newItem.Quantity != 5 {
t.Errorf("expected new item quantity 5, got %d", newItem.Quantity)
}
if newItem.Status != db.ComponentItemStatusEnumDamaged {
t.Errorf("expected new item status 'damaged', got '%s'", newItem.Status)
}
if newItem.ComponentID != orig.ComponentID {
t.Error("new item should have same component_id")
}
if newItem.ContainerID != orig.ContainerID {
t.Error("new item should have same container_id")
}
}
func TestUpdateComponentItemStatus_Case3_Merge_ExistingTarget(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 20, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
// Pre-create a damaged record to merge into
ctx := context.Background()
existingDamaged, err := q.CreateComponentItem(ctx, db.CreateComponentItemParams{
ComponentID: compID,
ContainerID: contID,
Quantity: 3,
Status: db.ComponentItemStatusEnumDamaged,
Metadata: []byte("{}"),
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("pre-create damaged record: %v", err)
}
changedQty := int32(5)
result, err := UpdateComponentItemStatus(ctx, pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "merge test", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify result
if result.NewComponentItemID != nil {
t.Error("expected NewComponentItemID to be nil for case 3")
}
if result.MergedComponentItemID == nil {
t.Fatal("expected MergedComponentItemID to be set for case 3")
}
if *result.MergedComponentItemID != existingDamaged.ID {
t.Errorf("expected merged ID %d, got %d", existingDamaged.ID, *result.MergedComponentItemID)
}
// Original record should have reduced quantity
orig, err := q.GetComponentItemByID(ctx, itemID)
if err != nil {
t.Fatalf("get original item: %v", err)
}
if orig.Quantity != 15 {
t.Errorf("expected original quantity 15, got %d", orig.Quantity)
}
// Merged record should have increased quantity
merged, err := q.GetComponentItemByID(ctx, existingDamaged.ID)
if err != nil {
t.Fatalf("get merged item: %v", err)
}
if merged.Quantity != 8 { // 3 + 5
t.Errorf("expected merged quantity 8, got %d", merged.Quantity)
}
if merged.Status != db.ComponentItemStatusEnumDamaged {
t.Errorf("expected merged status 'damaged', got '%s'", merged.Status)
}
}
func TestUpdateComponentItemStatus_Edge_StatusUnchanged(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumNormal, nil, "", "system")
if err == nil {
t.Fatal("expected error for status unchanged")
}
if err.Error() != "status unchanged" {
t.Errorf("expected 'status unchanged', got '%s'", err.Error())
}
}
func TestUpdateComponentItemStatus_Edge_ChangedQuantityZero(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
changedQty := int32(0)
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
if err == nil {
t.Fatal("expected error for zero changed_quantity")
}
}
func TestUpdateComponentItemStatus_Edge_ChangedQuantityNegative(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
changedQty := int32(-5)
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
if err == nil {
t.Fatal("expected error for negative changed_quantity")
}
}
func TestUpdateComponentItemStatus_Edge_ChangedQuantityExceedsQuantity(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
changedQty := int32(25)
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
if err == nil {
t.Fatal("expected error for changed_quantity > quantity")
}
}
func TestUpdateComponentItemStatus_Edge_ItemNotFound(t *testing.T) {
pool := testDB(t)
defer pool.Close()
_, err := UpdateComponentItemStatus(context.Background(), pool, 999999,
db.ComponentItemStatusEnumDamaged, nil, "", "system")
if err == nil {
t.Fatal("expected error for non-existent item")
}
}
func TestUpdateComponentItemStatus_Edge_QuantityBecomesZero(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 5, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
// Change all 5 to damaged → original record quantity becomes 0 → should be deleted
changedQty := int32(5)
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, &changedQty, "zero qty test", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The original record should be deleted (quantity became 0)
_, err = q.GetComponentItemByID(context.Background(), itemID)
if err == nil {
t.Error("expected original item to be deleted when quantity becomes 0")
}
}
func TestUpdateComponentItemStatus_ChangeToPendingInspection(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 30, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumPendingInspection, nil, "pending inspection check", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.StatusHistory.NewStatus != "pending_inspection" {
t.Errorf("expected new status 'pending_inspection', got '%s'", result.StatusHistory.NewStatus)
}
updated, _ := q.GetComponentItemByID(context.Background(), itemID)
if updated.Status != db.ComponentItemStatusEnumPendingInspection {
t.Errorf("expected status 'pending_inspection', got '%s'", updated.Status)
}
}
func TestUpdateComponentItemStatus_HistoryRecordIntegrity(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumLongUnused, nil, "history integrity test note", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
history := result.StatusHistory
if history.ComponentItemID != itemID {
t.Errorf("expected component_item_id %d, got %d", itemID, history.ComponentItemID)
}
if history.OldStatus != "normal" {
t.Errorf("expected old_status 'normal', got '%s'", history.OldStatus)
}
if history.NewStatus != "long_unused" {
t.Errorf("expected new_status 'long_unused', got '%s'", history.NewStatus)
}
if history.Note != "history integrity test note" {
t.Errorf("expected note 'history integrity test note', got '%s'", history.Note)
}
if history.ChangedBy != "system" {
t.Errorf("expected changed_by 'system', got '%s'", history.ChangedBy)
}
if history.ChangedQuantity != 10 {
t.Errorf("expected changed_quantity 10, got %d", history.ChangedQuantity)
}
if history.ID == 0 {
t.Error("expected history ID to be non-zero")
}
}
// TestUpdateComponentItemStatus_ResultType ensures the UpdateStatusResult domain
// model is populated correctly.
func TestUpdateComponentItemStatus_ResultType(t *testing.T) {
pool := testDB(t)
defer pool.Close()
q := db.New(pool)
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
defer cleanupSeeded(t, pool, itemID, compID, contID)
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
db.ComponentItemStatusEnumDamaged, nil, "", "system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify UpdateStatusResult fields
var _ models.UpdateStatusResult = result // compile-time check
if result.ComponentItem.ID == 0 && result.NewComponentItemID == nil && result.MergedComponentItemID == nil {
// Case 1 doesn't set ComponentItem either in current impl — that's fine
// ComponentItem is only set in Case 1 (change all). Let's verify.
// Actually looking at the repo code, ComponentItem is only set in Case 1.
// For Case 2/3 it's not set because the original item is only partially changed.
// This is intentional.
// For Case 1, ComponentItem should be set
if result.ComponentItem.ID == 0 {
t.Error("expected ComponentItem.ID to be set for Case 1 (change all)")
}
}
if result.StatusHistory.ID == 0 {
t.Error("expected StatusHistory.ID to be non-zero")
}
}