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,238 @@
package repositories
import (
"context"
"errors"
"fmt"
"time"
"wm-backend/global"
"wm-backend/internal/mapper"
"wm-backend/internal/models"
db "wm-backend/sqlc_gen"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
func CreateComponentItem(ctx context.Context, queries *db.Queries, body models.ComponentItem) (models.ComponentItem, error) {
result, err := queries.CreateComponentItem(ctx, *mapper.ToModelComponentItem(&body))
if err != nil {
log.Error().Err(err).Msg("Failed to create component item")
return models.ComponentItem{}, err
}
return *mapper.ToDomainComponentItem(result), nil
}
func GetComponentItemByID(ctx context.Context, queries *db.Queries, id int64) (models.ComponentItem, error) {
result, err := queries.GetComponentItemByID(ctx, id)
if err != nil {
return models.ComponentItem{}, err
}
return *mapper.ToDomainComponentItem(result), nil
}
func ListComponentItems(ctx context.Context, queries *db.Queries) ([]models.ComponentItem, error) {
results, err := queries.ListComponentItems(ctx)
if err != nil {
return nil, err
}
var items []models.ComponentItem
for _, r := range results {
items = append(items, *mapper.ToDomainComponentItem(r))
}
return items, nil
}
func UpdateComponentItem(ctx context.Context, queries *db.Queries, body models.ComponentItem) (models.ComponentItem, error) {
result, err := queries.UpdateComponentItem(ctx, *mapper.ToUpdateModelComponentItem(&body))
if err != nil {
return models.ComponentItem{}, err
}
return *mapper.ToDomainComponentItem(result), nil
}
func DeleteComponentItem(ctx context.Context, queries *db.Queries, id int64) (int64, error) {
rowsAffected, err := queries.DeleteComponentItem(ctx, id)
if err != nil {
return rowsAffected, err
}
return rowsAffected, nil
}
func FindComponentItems(ctx context.Context, queries *db.Queries, componentID int64) ([]models.FindComponentItemResult, error) {
results, err := queries.FindComponentItem(ctx, componentID)
if err != nil {
return nil, err
}
var items []models.FindComponentItemResult
for _, r := range results {
items = append(items, *mapper.ToDomainFindComponentItem(r))
}
return items, nil
}
// UpdateComponentItemStatus changes the status of a component item within a transaction.
// It handles three cases:
// - Case 1: changedQuantity is nil or >= quantity → change status of entire record
// - Case 2: changedQuantity < quantity, no existing record with target status → split into 2 records
// - Case 3: changedQuantity < quantity, existing record with target status → merge quantities
//
// Returns UpdateStatusResult with case-specific fields populated.
func UpdateComponentItemStatus(ctx context.Context, dbPool *pgxpool.Pool, id int64, newStatus db.ComponentItemStatusEnum,
changedQuantity *int32,
note string,
changedBy string) (result models.UpdateStatusResult, err error) {
// 1. Begin transaction
tx, err := dbPool.Begin(ctx)
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("begin tx: %w", err)
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
txQueries := global.Queries.WithTx(tx)
// 2. Get existing item
existingItem, err := txQueries.GetComponentItemByID(ctx, id)
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("item not found: %w", err)
}
// 3. Check status unchanged
if existingItem.Status == newStatus {
tx.Rollback(ctx)
return models.UpdateStatusResult{}, fmt.Errorf("status unchanged")
}
// 4. Determine changed quantity (nil = change all)
var changedQty int32
if changedQuantity == nil {
changedQty = existingItem.Quantity
} else {
changedQty = *changedQuantity
}
// 5. Validate changed quantity
if changedQty <= 0 {
return models.UpdateStatusResult{}, fmt.Errorf("changed_quantity must be positive, got %d", changedQty)
}
if changedQty > existingItem.Quantity {
return models.UpdateStatusResult{}, fmt.Errorf("changed_quantity (%d) exceeds current quantity (%d)", changedQty, existingItem.Quantity)
}
// 6. Branch by case
if changedQty >= existingItem.Quantity {
// ── Case 1: Change status of entire record ──
updatedItem, err := txQueries.UpdateComponentItemStatus(ctx, db.UpdateComponentItemStatusParams{
ID: id,
Status: newStatus,
UpdatedAt: time.Now(),
})
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("update status: %w", err)
}
result.ComponentItem = *mapper.ToDomainComponentItem(updatedItem)
} else {
// ── Case 2 & 3: Partial change ──
// 6a. Reduce quantity on original record (or delete if qty becomes 0)
newQty := existingItem.Quantity - changedQty
if newQty == 0 {
_, err = txQueries.DeleteComponentItem(ctx, id)
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("delete zero-qty record: %w", err)
}
} else {
_, err = txQueries.UpdateComponentItemQuantity(ctx, db.UpdateComponentItemQuantityParams{
ID: id,
Quantity: newQty,
UpdatedAt: time.Now(),
})
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("update quantity: %w", err)
}
}
// 6b. Find existing record with same (component_id, container_id, new_status)
existingNewStatus, findErr := txQueries.GetComponentItemByComponentContainerStatus(ctx,
db.GetComponentItemByComponentContainerStatusParams{
ComponentID: existingItem.ComponentID,
ContainerID: existingItem.ContainerID,
Status: newStatus,
})
if findErr != nil {
if errors.Is(findErr, pgx.ErrNoRows) {
// ── Case 2: No existing record → create new one ──
newItem, createErr := txQueries.CreateComponentItem(ctx, db.CreateComponentItemParams{
ComponentID: existingItem.ComponentID,
ContainerID: existingItem.ContainerID,
Quantity: changedQty,
Status: newStatus,
Metadata: existingItem.Metadata,
CreatedAt: time.Now(),
})
if createErr != nil {
return models.UpdateStatusResult{}, fmt.Errorf("create new item: %w", createErr)
}
newID := newItem.ID
result.NewComponentItemID = &newID
} else {
return models.UpdateStatusResult{}, fmt.Errorf("find existing new status: %w", findErr)
}
} else {
// ── Case 3: Existing record → merge quantities ──
mergedQty := existingNewStatus.Quantity + changedQty
_, err = txQueries.UpdateComponentItemQuantity(ctx, db.UpdateComponentItemQuantityParams{
ID: existingNewStatus.ID,
Quantity: mergedQty,
UpdatedAt: time.Now(),
})
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("merge quantity: %w", err)
}
mergedID := existingNewStatus.ID
result.MergedComponentItemID = &mergedID
}
}
// 7. Insert status history record
history, err := txQueries.CreateComponentStatusHistory(ctx, db.CreateComponentStatusHistoryParams{
ComponentItemID: id,
OldStatus: db.NullComponentItemStatusEnum{
ComponentItemStatusEnum: existingItem.Status,
Valid: true,
},
NewStatus: newStatus,
ChangedQuantity: pgtype.Int4{
Int32: changedQty,
Valid: true,
},
Note: pgtype.Text{
String: note,
Valid: note != "",
},
ChangedBy: pgtype.Text{
String: changedBy,
Valid: true,
},
ChangedAt: time.Now(),
})
if err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("insert history: %w", err)
}
result.StatusHistory = *mapper.ToDomainComponentStatusHistory(history)
// 8. Commit transaction
if err = tx.Commit(ctx); err != nil {
return models.UpdateStatusResult{}, fmt.Errorf("commit: %w", err)
}
return result, nil
}

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