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") } }