feat: sync account freeze lifecycle

This commit is contained in:
A 2026-07-15 20:24:37 +08:00
parent 76bfc5100f
commit 47fcf0ea41
40 changed files with 1363 additions and 196 deletions

View file

@ -4,13 +4,14 @@ import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
restrictions := &fakeRestrictionStore{}
@ -20,10 +21,12 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
Now: fixedNow,
})
dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{
dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Frozen: true,
Until: fixedNow().Add(7 * 24 * time.Hour),
AppealURL: "https://appeals.example.test/account/1001",
})
if err != nil {
t.Fatalf("dry-run freeze: %v", err)
@ -32,23 +35,29 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls)
}
execReq := SetSendFrozenRequest{
execReq := SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false},
UserID: 1001,
Frozen: true,
Until: fixedNow().Add(7 * 24 * time.Hour),
AppealURL: "https://appeals.example.test/account/1001",
}
exec, err := svc.SetSendFrozen(ctx, execReq)
exec, err := svc.SetAccountFrozen(ctx, execReq)
if err != nil {
t.Fatalf("execute freeze: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
}
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("CanSendMessages err=%v, want ErrUserSendRestricted", err)
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || !freeze.Frozen || !freeze.Since.Equal(fixedNow()) || freeze.AppealURL != execReq.AppealURL {
t.Fatalf("AccountFreeze = %+v found=%v err=%v", freeze, found, err)
}
again, err := svc.SetSendFrozen(ctx, execReq)
again, err := svc.SetAccountFrozen(ctx, execReq)
if err != nil {
t.Fatalf("duplicate freeze: %v", err)
}
@ -57,6 +66,101 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
}
}
func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) {
ctx := context.Background()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Restrictions: restrictions, Now: fixedNow})
for _, req := range []SetAccountFrozenRequest{
{CommandMeta: CommandMeta{CommandID: "bad-until", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow(), AppealURL: "https://appeals.example.test"},
{CommandMeta: CommandMeta{CommandID: "too-far", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: time.Unix(1<<31, 0), AppealURL: "https://appeals.example.test"},
{CommandMeta: CommandMeta{CommandID: "bad-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "javascript:bad"},
{CommandMeta: CommandMeta{CommandID: "long-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "https://appeals.example.test/" + strings.Repeat("x", maxFreezeAppealURLLength)},
} {
if _, err := svc.SetAccountFrozen(ctx, req); err == nil {
t.Fatalf("SetAccountFrozen(%+v) succeeded", req)
}
}
freezeReq := SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "freeze", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(24 * time.Hour), AppealURL: "https://appeals.example.test"}
if _, err := svc.SetAccountFrozen(ctx, freezeReq); err != nil {
t.Fatal(err)
}
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "unfreeze", Actor: "ops", Reason: "accepted"}, UserID: 1001}); err != nil {
t.Fatal(err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || freeze.Frozen || !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
t.Fatalf("unfrozen state = %+v found=%v err=%v", freeze, found, err)
}
}
func TestSetAccountFrozenUpdatePreservesOriginalSince(t *testing.T) {
ctx := context.Background()
now := fixedNow()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Restrictions: restrictions,
Now: func() time.Time { return now },
})
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-initial", Actor: "ops", Reason: "review"},
UserID: 1001,
Frozen: true,
Until: now.Add(24 * time.Hour),
AppealURL: "https://appeals.example.test/initial",
}); err != nil {
t.Fatal(err)
}
originalSince := now
now = now.Add(2 * time.Hour)
updatedUntil := now.Add(72 * time.Hour)
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-update", Actor: "ops", Reason: "extend review"},
UserID: 1001,
Frozen: true,
Until: updatedUntil,
AppealURL: "https://appeals.example.test/updated",
}); err != nil {
t.Fatal(err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || !freeze.Since.Equal(originalSince) || !freeze.Until.Equal(updatedUntil) ||
freeze.AppealURL != "https://appeals.example.test/updated" {
t.Fatalf("updated freeze = %+v found=%v err=%v", freeze, found, err)
}
}
func TestSetAccountFrozenReplayRemainsIdempotentAfterDeadline(t *testing.T) {
ctx := context.Background()
now := fixedNow()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Restrictions: restrictions,
Now: func() time.Time { return now },
})
req := SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-expiring", Actor: "ops", Reason: "review"},
UserID: 1001,
Frozen: true,
Until: now.Add(time.Hour),
AppealURL: "https://appeals.example.test/expiring",
}
if _, err := svc.SetAccountFrozen(ctx, req); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
replayed, err := svc.SetAccountFrozen(ctx, req)
if err != nil || !replayed.AlreadyExecuted || restrictions.setCalls != 1 {
t.Fatalf("expired replay = %+v err=%v setCalls=%d", replayed, err, restrictions.setCalls)
}
stale := req
stale.CommandID = "new-stale-freeze"
if _, err := svc.SetAccountFrozen(ctx, stale); err == nil || restrictions.setCalls != 1 {
t.Fatalf("new stale request err=%v setCalls=%d, want rejection without state mutation", err, restrictions.setCalls)
}
}
func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
@ -365,21 +469,21 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
}
type fakeRestrictionStore struct {
items map[int64]domain.AccountSendRestriction
items map[int64]domain.AccountFreeze
setCalls int
}
func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
if f.items == nil {
return domain.AccountSendRestriction{}, false, nil
return domain.AccountFreeze{}, false, nil
}
r, ok := f.items[userID]
return r, ok, nil
}
func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.AccountFreeze) (domain.AccountFreeze, error) {
if f.items == nil {
f.items = map[int64]domain.AccountSendRestriction{}
f.items = map[int64]domain.AccountFreeze{}
}
f.setCalls++
r.UpdatedAt = fixedNow()
@ -387,13 +491,6 @@ func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.Ac
return r, nil
}
func (f *fakeRestrictionStore) IsSendFrozen(_ context.Context, userID int64) (bool, error) {
if f.items == nil {
return false, nil
}
return f.items[userID].Frozen, nil
}
type fakeMessagesService struct {
byID []domain.Message
deleteCalls int