feat(accountdelete): drop all-robot games + unspoofable [Deleted] label
Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or auto-match-robot; children cascade), keeping games with any human seat (anonymized instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids brackets, so no live player can impersonate a deleted account. Integration test covers drop-vs-keep.
This commit is contained in:
@@ -25,8 +25,10 @@ import (
|
||||
|
||||
// AnonymizedName is the label a deleted account shows to opponents. Display names are
|
||||
// stored strings resolved identically for every viewer (no per-viewer localisation in this
|
||||
// codebase), so a single canonical label is used.
|
||||
const AnonymizedName = "Удалённый игрок"
|
||||
// codebase), so a single canonical label is used. The brackets are deliberate: the
|
||||
// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a
|
||||
// name that impersonates a deleted account.
|
||||
const AnonymizedName = "[Deleted]"
|
||||
|
||||
// retainDelete is the retained_identities reason written when a credential is journalled
|
||||
// because its account is being deleted.
|
||||
@@ -70,6 +72,38 @@ func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID
|
||||
})
|
||||
}
|
||||
|
||||
// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a
|
||||
// human — a robot seat is one whose account holds a 'robot' identity, so this covers both
|
||||
// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their
|
||||
// moves/chat/players/complaints fall away through ON DELETE CASCADE.
|
||||
const dropAllRobotGamesSQL = `
|
||||
DELETE FROM games g
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM game_players o
|
||||
WHERE o.game_id = g.game_id AND o.account_id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot'
|
||||
)
|
||||
)`
|
||||
|
||||
// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or
|
||||
// auto-match-robot games), returning how many were removed. Games with any human seat are
|
||||
// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the
|
||||
// account's active games are resigned, so no live game is removed under the robot driver.
|
||||
func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) {
|
||||
res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("accountdelete: dropped games count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// journalAndDropIdentities copies the account's live identities into the retention journal
|
||||
// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse.
|
||||
func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountdelete"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
|
||||
@@ -90,3 +92,44 @@ func TestAnonymizeAndTombstone(t *testing.T) {
|
||||
t.Fatalf("email should be free after deletion, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
|
||||
// opponent.
|
||||
func TestDropAllRobotGames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gsvc := newGameService()
|
||||
robots := newRobotService(t, gsvc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
mm := newMatchmaker(t, robots, time.Minute, 0)
|
||||
deleter := accountdelete.NewDeleter(testDB)
|
||||
|
||||
user := provisionAccount(t)
|
||||
other := provisionAccount(t)
|
||||
|
||||
aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("start vs AI: %v", err)
|
||||
}
|
||||
humanGame, err := gsvc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create human game: %v", err)
|
||||
}
|
||||
|
||||
n, err := deleter.DropAllRobotGames(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("drop: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
|
||||
}
|
||||
if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
|
||||
t.Error("the vs-AI game should be dropped")
|
||||
}
|
||||
if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
|
||||
t.Errorf("the human game should be kept, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user