package tests import ( "sync" "testing" ) func firstItemID(t *testing.T, commandID int) int { t.Helper() var itemID int if err := testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ? LIMIT 1`, commandID).Scan(&itemID).Error; err != nil { t.Fatalf("lecture item de la commande %d: %v", commandID, err) } return itemID } // Supprimer un item d'une commande encore active doit restituer le stock de // cet item. func TestDeleteCommandItem_RestoresStockOnActiveOrder(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "delitem_active") productID := newTestProduct(t, "DelItemActive", 5) cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) itemID := firstItemID(t, cmdID) if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil { t.Fatalf("DeleteCommandItem: %v", err) } if got := productStock(t, productID); got != 8 { t.Errorf("stock après suppression d'item sur commande active (5 initial + 3 remboursés): got=%.2f want=8", got) } var remaining int64 testDB.GDB.Raw(`SELECT COUNT(*) FROM command_items WHERE id = ?`, itemID).Scan(&remaining) if remaining != 0 { t.Errorf("l'item doit être supprimé: got=%d lignes restantes", remaining) } } // Sur une commande déjà terminale (approuvée/livrée/annulée), le stock a // déjà été traité par le chemin correspondant — supprimer un item a // posteriori ne doit pas rembourser une seconde fois. func TestDeleteCommandItem_DoesNotRestoreStockOnTerminalOrder(t *testing.T) { for _, status := range []string{"approved", "livre", "cancelled"} { t.Run(status, func(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "delitem_"+status) productID := newTestProduct(t, "DelItem"+status, 5) cmdID := newTestCommandWithItem(t, username, status, "", productID, 3, 30) itemID := firstItemID(t, cmdID) if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil { t.Fatalf("DeleteCommandItem: %v", err) } if got := productStock(t, productID); got != 5 { t.Errorf("stock ne doit pas bouger pour une commande %q: got=%.2f want=5", status, got) } }) } } // DeleteCommandItem verrouille désormais le statut de la commande (FOR // UPDATE) avant de décider de rembourser, dans la même transaction — ce test // vérifie qu'une suppression d'item et une annulation complète de la même // commande, déclenchées en concurrence, ne remboursent le stock qu'une // seule fois (peu importe laquelle des deux "gagne" la course). func TestDeleteCommandItem_ConcurrentWithFullCancelDoesNotDoubleRefund(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "delitem_concurrent") productID := newTestProduct(t, "DelItemConcurrent", 5) cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) itemID := firstItemID(t, cmdID) var wg sync.WaitGroup start := make(chan struct{}) wg.Add(2) go func() { defer wg.Done() <-start _ = testDB.DeleteCommandItem(cmdID, itemID) }() go func() { defer wg.Done() <-start _, _ = testDB.CancelCommandAtomic(cmdID, username, "test", false) }() close(start) wg.Wait() if got := productStock(t, productID); got != 8 { t.Errorf("stock après suppression d'item + annulation complète concurrentes (5 initial + 3 remboursés une seule fois attendu): got=%.2f want=8", got) } } // ── SetProductStock (override direct admin) ───────────────────────────────── func TestSetProductStock_SetsExactValue(t *testing.T) { cleanupStockTestData(t) productID := newTestProduct(t, "SetStockDirect", 5) if err := testDB.SetProductStock(productID, 42); err != nil { t.Fatalf("SetProductStock: %v", err) } if got := productStock(t, productID); got != 42 { t.Errorf("stock après SetProductStock: got=%.2f want=42", got) } } func TestSetProductStock_RejectsUnknownProduct(t *testing.T) { cleanupStockTestData(t) if err := testDB.SetProductStock(-1, 10); err == nil { t.Fatal("attendu une erreur pour un produit inexistant") } }