//go:build integration package inttest import ( "context" "errors" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/account" ) // TestSuspensionRoundTrip covers a permanent block: a fresh account is not blocked, Suspend with // a reason makes CurrentSuspension report it (permanent, with the en/ru snapshot resolved per // language), and LiftSuspension reverses it. func TestSuspensionRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) id := provisionAccount(t) if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { t.Fatalf("fresh CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err) } if _, err := store.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil { t.Fatalf("suspend: %v", err) } susp, ok, err := store.CurrentSuspension(ctx, id) if err != nil || !ok { t.Fatalf("CurrentSuspension after suspend = (ok %v, err %v), want (true, nil)", ok, err) } if !susp.Permanent() { t.Errorf("Permanent() = false, want true for a nil-until block") } if got := susp.LocalizedReason("en"); got != "Spam" { t.Errorf("LocalizedReason(en) = %q, want Spam", got) } if got := susp.LocalizedReason("ru"); got != "Спам" { t.Errorf("LocalizedReason(ru) = %q, want Спам", got) } if got := susp.LocalizedReason("fr"); got != "Spam" { t.Errorf("LocalizedReason(fr) = %q, want the English fallback Spam", got) } if err := store.LiftSuspension(ctx, id); err != nil { t.Fatalf("lift: %v", err) } if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err) } } // TestTemporarySuspensionExpiry checks the until boundary: a future expiry is in force (and not // permanent), while a past expiry is already not in force. func TestTemporarySuspensionExpiry(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) future := provisionAccount(t) until := time.Now().Add(time.Hour) if _, err := store.Suspend(ctx, future, &until, "", "", nil); err != nil { t.Fatalf("suspend future: %v", err) } susp, ok, err := store.CurrentSuspension(ctx, future) if err != nil || !ok { t.Fatalf("future CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) } if susp.Permanent() { t.Error("Permanent() = true, want false for a dated block") } if susp.BlockedUntil == nil || !susp.BlockedUntil.After(time.Now()) { t.Errorf("BlockedUntil = %v, want a future instant", susp.BlockedUntil) } past := provisionAccount(t) expired := time.Now().Add(-time.Hour) if _, err := store.Suspend(ctx, past, &expired, "", "", nil); err != nil { t.Fatalf("suspend past: %v", err) } if _, ok, err := store.CurrentSuspension(ctx, past); err != nil || ok { t.Fatalf("expired CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err) } } // TestSuspensionStrongestWins checks that with overlapping blocks CurrentSuspension returns the // strongest — a permanent block outranks any dated one — and that LiftSuspension clears them all. func TestSuspensionStrongestWins(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) id := provisionAccount(t) soon := time.Now().Add(time.Hour) if _, err := store.Suspend(ctx, id, &soon, "", "", nil); err != nil { t.Fatalf("suspend dated: %v", err) } if _, err := store.Suspend(ctx, id, nil, "", "", nil); err != nil { t.Fatalf("suspend permanent: %v", err) } later := time.Now().Add(2 * time.Hour) if _, err := store.Suspend(ctx, id, &later, "", "", nil); err != nil { t.Fatalf("suspend dated-later: %v", err) } susp, ok, err := store.CurrentSuspension(ctx, id) if err != nil || !ok { t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) } if !susp.Permanent() { t.Error("strongest block should be the permanent one") } if err := store.LiftSuspension(ctx, id); err != nil { t.Fatalf("lift: %v", err) } if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err) } } // TestReasonsCRUD covers the operator-editable picklist: create, list, get, update, delete, and // the ErrNotFound paths. func TestReasonsCRUD(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) created, err := store.CreateReason(ctx, "Cheating", "Читерство") if err != nil { t.Fatalf("create reason: %v", err) } if created.ID == uuid.Nil || created.TextEn != "Cheating" || created.TextRu != "Читерство" { t.Fatalf("created reason = %+v, want populated en/ru and id", created) } got, err := store.GetReason(ctx, created.ID) if err != nil { t.Fatalf("get reason: %v", err) } if got.TextEn != "Cheating" { t.Errorf("get TextEn = %q, want Cheating", got.TextEn) } list, err := store.ListReasons(ctx) if err != nil { t.Fatalf("list reasons: %v", err) } if !containsReason(list, created.ID) { t.Error("created reason missing from ListReasons") } updated, err := store.UpdateReason(ctx, created.ID, "Abuse", "Оскорбления") if err != nil { t.Fatalf("update reason: %v", err) } if updated.TextEn != "Abuse" || updated.TextRu != "Оскорбления" { t.Errorf("updated reason = %+v, want Abuse/Оскорбления", updated) } if err := store.DeleteReason(ctx, created.ID); err != nil { t.Fatalf("delete reason: %v", err) } if _, err := store.GetReason(ctx, created.ID); !errors.Is(err, account.ErrNotFound) { t.Errorf("get deleted reason = %v, want ErrNotFound", err) } if _, err := store.UpdateReason(ctx, uuid.New(), "x", "y"); !errors.Is(err, account.ErrNotFound) { t.Errorf("update missing reason = %v, want ErrNotFound", err) } } // TestSuspensionReasonSnapshotSurvivesDelete checks that a block keeps the reason text it was // stamped with even after the picklist entry is deleted (the FK nulls reason_id, the snapshot // stays). func TestSuspensionReasonSnapshotSurvivesDelete(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) id := provisionAccount(t) reason, err := store.CreateReason(ctx, "Toxicity", "Токсичность") if err != nil { t.Fatalf("create reason: %v", err) } if _, err := store.Suspend(ctx, id, nil, reason.TextEn, reason.TextRu, &reason.ID); err != nil { t.Fatalf("suspend with reason: %v", err) } if err := store.DeleteReason(ctx, reason.ID); err != nil { t.Fatalf("delete reason: %v", err) } susp, ok, err := store.CurrentSuspension(ctx, id) if err != nil || !ok { t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) } if susp.LocalizedReason("en") != "Toxicity" || susp.LocalizedReason("ru") != "Токсичность" { t.Errorf("reason snapshot lost after delete: en=%q ru=%q", susp.LocalizedReason("en"), susp.LocalizedReason("ru")) } } // containsReason reports whether list holds a reason with the given id. func containsReason(list []account.Reason, id uuid.UUID) bool { for _, r := range list { if r.ID == id { return true } } return false }