package maintenance import ( "context" "errors" "testing" "time" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" ) type fakeOutboxRetention struct { calls int olderThan time.Duration limit int deleted int } func (f *fakeOutboxRetention) DeleteFailed(_ context.Context, olderThan time.Duration, limit int) (int, error) { f.calls++ f.olderThan = olderThan f.limit = limit return f.deleted, nil } func TestRetentionWorkerUsesIndependentOutboxPoisonPolicyAndSignalsRelease(t *testing.T) { core, logs := observer.New(zapcore.ErrorLevel) outbox := &fakeOutboxRetention{deleted: 2} w := NewRetentionWorker(outbox, nil, zap.New(core), 7*24*time.Hour, time.Hour, 73). WithDispatchOutboxPoisonPolicy(2*time.Minute, 7*time.Second) w.runOnce(context.Background()) if outbox.calls != 1 || outbox.olderThan != 2*time.Minute || outbox.limit != 73 { t.Fatalf("outbox poison calls/args = %d/%v/%d, want 1/2m/73", outbox.calls, outbox.olderThan, outbox.limit) } entries := logs.FilterMessage("terminal failed dispatch_outbox 已结束隔离并释放用户 lane").All() if len(entries) != 1 { t.Fatalf("poison release error signals = %d, want 1", len(entries)) } if got := entries[0].ContextMap()["signal"]; got != "dispatch_outbox_poison_released" { t.Fatalf("poison signal = %v", got) } } func TestRetentionWorkerOutboxPoisonPolicyDefaultsAreShort(t *testing.T) { outbox := &fakeOutboxRetention{} w := NewRetentionWorker(outbox, nil, zap.NewNop(), 168*time.Hour, time.Hour, 100). WithDispatchOutboxPoisonPolicy(0, 0) w.runOutboxPoisonOnce(context.Background()) if outbox.olderThan != defaultOutboxPoisonRetention || w.outboxPoisonInterval != defaultOutboxPoisonInterval { t.Fatalf("default poison policy = %v/%v, want %v/%v", outbox.olderThan, w.outboxPoisonInterval, defaultOutboxPoisonRetention, defaultOutboxPoisonInterval) } } type fakeTempKeyRetention struct { calls int expiredBefore int64 limit int } func (f *fakeTempKeyRetention) DeleteExpired(_ context.Context, expiredBefore int64, limit int) (int, error) { f.calls++ f.expiredBefore = expiredBefore f.limit = limit return 3, nil } func TestRetentionWorkerReclaimsExpiredTempKeys(t *testing.T) { outbox := &fakeOutboxRetention{} temp := &fakeTempKeyRetention{} w := NewRetentionWorker(outbox, temp, zap.NewNop(), time.Hour, time.Hour, 100) w.runOnce(context.Background()) if outbox.calls != 1 || temp.calls != 1 { t.Fatalf("calls outbox=%d temp=%d, want 1/1", outbox.calls, temp.calls) } if temp.limit != 100 { t.Fatalf("limit = %d, want batch 100", temp.limit) } wantBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix() if diff := temp.expiredBefore - wantBefore; diff < -5 || diff > 5 { t.Fatalf("expiredBefore = %d, want ≈ now-grace (%d)", temp.expiredBefore, wantBefore) } } func TestRetentionWorkerSkipsNilTempKeyStore(t *testing.T) { outbox := &fakeOutboxRetention{} w := NewRetentionWorker(outbox, nil, zap.NewNop(), time.Hour, time.Hour, 100) w.runOnce(context.Background()) // 不应 panic if outbox.calls != 1 { t.Fatalf("outbox calls = %d, want 1", outbox.calls) } } type fakeBotAPIRetention struct { calls int confirmedGrace time.Duration maxAge time.Duration limit int } type fakeLoginCodeDeliveryRetention struct { calls int expiredBefore time.Time limit int } func (f *fakeLoginCodeDeliveryRetention) DeleteExpiredLoginCodeDeliveries(_ context.Context, expiredBefore time.Time, limit int) (int, error) { f.calls++ f.expiredBefore = expiredBefore f.limit = limit return 4, nil } func TestRetentionWorkerReclaimsExpiredLoginCodeDeliveryReceipts(t *testing.T) { loginCodes := &fakeLoginCodeDeliveryRetention{} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), 168*time.Hour, time.Hour, 83). WithLoginCodeDeliveryRetention(loginCodes) before := time.Now() w.runRetentionOnce(context.Background()) after := time.Now() if loginCodes.calls != 1 || loginCodes.limit != 83 { t.Fatalf("login-code retention calls/limit = %d/%d, want 1/83", loginCodes.calls, loginCodes.limit) } if loginCodes.expiredBefore.Before(before) || loginCodes.expiredBefore.After(after) { t.Fatalf("login-code expiry boundary = %v, want within [%v,%v]", loginCodes.expiredBefore, before, after) } } type fakeReportRetention struct { telemetryBefore time.Time authBefore time.Time sponsoredBefore time.Time appealBefore time.Time telemetryCalls int authCalls int sponsoredCalls int appealCalls int limit int } func (f *fakeReportRetention) DeleteExpiredClientTelemetry(_ context.Context, before time.Time, limit int) (int, error) { f.telemetryCalls++ f.telemetryBefore = before f.limit = limit return 1, nil } func (f *fakeReportRetention) DeleteExpiredAuthDeliveryReports(_ context.Context, before time.Time, limit int) (int, error) { f.authCalls++ f.authBefore = before f.limit = limit return 1, nil } func (f *fakeReportRetention) DeleteExpiredSponsoredMessageImpressions(_ context.Context, before time.Time, limit int) (int, error) { f.sponsoredCalls++ f.sponsoredBefore = before f.limit = limit return 1, nil } func (f *fakeReportRetention) DeleteExpiredModerationAppealLinks(_ context.Context, before time.Time, limit int) (int, error) { f.appealCalls++ f.appealBefore = before f.limit = limit return 1, nil } func TestRetentionWorkerSeparatesTelemetryDiagnosticsAndModerationCapabilities(t *testing.T) { const ( telemetryTTL = 7 * 24 * time.Hour authTTL = 14 * 24 * time.Hour batch = 47 ) store := &fakeReportRetention{} w := NewRetentionWorker( &fakeOutboxRetention{}, nil, zap.NewNop(), 168*time.Hour, time.Hour, batch, ).WithClientTelemetryRetention(store, telemetryTTL). WithAuthDeliveryReportRetention(store, authTTL). WithModerationRetention(store) before := time.Now() w.runRetentionOnce(context.Background()) after := time.Now() if store.telemetryCalls != 1 || store.authCalls != 1 || store.sponsoredCalls != 1 || store.appealCalls != 1 || store.limit != batch { t.Fatalf("calls telemetry/auth/sponsored/appeal=%d/%d/%d/%d limit=%d", store.telemetryCalls, store.authCalls, store.sponsoredCalls, store.appealCalls, store.limit) } if store.telemetryBefore.Before(before.Add(-telemetryTTL)) || store.telemetryBefore.After(after.Add(-telemetryTTL)) { t.Fatalf("telemetry boundary=%v", store.telemetryBefore) } if store.authBefore.Before(before.Add(-authTTL)) || store.authBefore.After(after.Add(-authTTL)) { t.Fatalf("auth boundary=%v", store.authBefore) } if store.sponsoredBefore.Before(before) || store.sponsoredBefore.After(after) || store.appealBefore.Before(before) || store.appealBefore.After(after) { t.Fatalf("moderation capability boundaries sponsored=%v appeal=%v", store.sponsoredBefore, store.appealBefore) } } func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) { f.calls++ f.confirmedGrace = confirmedGrace f.maxAge = maxAge f.limit = limit return 5, nil } func TestRetentionWorkerReclaimsBotAPIUpdates(t *testing.T) { outbox := &fakeOutboxRetention{} botAPI := &fakeBotAPIRetention{} w := NewRetentionWorker(outbox, nil, zap.NewNop(), time.Hour, time.Hour, 100). WithBotAPIUpdateRetention(botAPI, 24*time.Hour) w.runOnce(context.Background()) if botAPI.calls != 1 { t.Fatalf("bot api retention calls = %d, want 1", botAPI.calls) } if botAPI.confirmedGrace != botAPIConfirmedGrace || botAPI.maxAge != 24*time.Hour || botAPI.limit != 100 { t.Fatalf("bot api retention args = (%v, %v, %d), want (%v, 24h, 100)", botAPI.confirmedGrace, botAPI.maxAge, botAPI.limit, botAPIConfirmedGrace) } } func TestRetentionWorkerBotAPIRetentionDefaultsTo24h(t *testing.T) { botAPI := &fakeBotAPIRetention{} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 100). WithBotAPIUpdateRetention(botAPI, 0) w.runOnce(context.Background()) if botAPI.maxAge != 24*time.Hour { t.Fatalf("default bot api retention = %v, want 24h", botAPI.maxAge) } } type fakeUserUpdateRetention struct { calls int olderThan time.Duration limit int } func (f *fakeUserUpdateRetention) DeleteConfirmedPrefix(_ context.Context, olderThan time.Duration, limit int) (int, error) { f.calls++ f.olderThan = olderThan f.limit = limit return 9, nil } func TestRetentionWorkerReclaimsOnlyConfirmedUserUpdatePrefix(t *testing.T) { const retention = 7 * 24 * time.Hour store := &fakeUserUpdateRetention{} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), retention, time.Hour, 91). WithUserUpdateRetention(store) w.runOnce(context.Background()) if store.calls != 1 || store.olderThan != retention || store.limit != 91 { t.Fatalf("user update retention calls/args = %d/%v/%d, want 1/%v/91", store.calls, store.olderThan, store.limit, retention) } } type fakeChannelUpdateRetention struct { calls int olderThan time.Duration limit int } func (f *fakeChannelUpdateRetention) DeleteExpiredChannelUpdateEvents(_ context.Context, olderThan time.Duration, limit int) (int, error) { f.calls++ f.olderThan = olderThan f.limit = limit return 7, nil } func TestRetentionWorkerReclaimsChannelUpdates(t *testing.T) { const ( retention = 14 * 24 * time.Hour batch = 321 ) channelUpdates := &fakeChannelUpdateRetention{} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), retention, time.Hour, batch). WithChannelUpdateRetention(channelUpdates) w.runOnce(context.Background()) if channelUpdates.calls != 1 { t.Fatalf("channel update retention calls = %d, want 1", channelUpdates.calls) } if channelUpdates.olderThan != retention || channelUpdates.limit != batch { t.Fatalf("channel update retention args = (%v, %d), want (%v, %d)", channelUpdates.olderThan, channelUpdates.limit, retention, batch) } } type fakeOrphanAuthKeyRetention struct { calls int olderThan time.Duration limit int protected [][8]byte } func (f *fakeOrphanAuthKeyRetention) DeleteOrphaned(_ context.Context, olderThan time.Duration, limit int, protected [][8]byte) (int, error) { f.calls++ f.olderThan = olderThan f.limit = limit f.protected = append([][8]byte(nil), protected...) return 2, nil } type fakeActiveRawAuthKeys struct{ ids [][8]byte } func (f fakeActiveRawAuthKeys) ActiveRawAuthKeyIDs() [][8]byte { return append([][8]byte(nil), f.ids...) } func TestRetentionWorkerProtectsActiveRawAuthKeysFromOrphanGC(t *testing.T) { store := &fakeOrphanAuthKeyRetention{} active := fakeActiveRawAuthKeys{ids: [][8]byte{{1}, {2}}} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 73). WithOrphanAuthKeyRetention(store, active, 24*time.Hour) w.runOnce(context.Background()) if store.calls != 1 || store.olderThan != 24*time.Hour || store.limit != 73 { t.Fatalf("orphan retention calls/args = %d/%v/%d, want 1/24h/73", store.calls, store.olderThan, store.limit) } if len(store.protected) != 2 || store.protected[0] != ([8]byte{1}) || store.protected[1] != ([8]byte{2}) { t.Fatalf("protected raw auth keys = %v, want {1},{2}", store.protected) } } type fakeHeartbeatOrphanRetention struct { fakeOrphanAuthKeyRetention heartbeatCalls int heartbeatIDs [][8]byte heartbeatErr error } func (f *fakeHeartbeatOrphanRetention) TouchActiveRawAuthKeys(_ context.Context, ids [][8]byte) error { f.heartbeatCalls++ f.heartbeatIDs = append([][8]byte(nil), ids...) return f.heartbeatErr } func TestRetentionWorkerHeartbeatsActiveKeysBeforeOrphanDelete(t *testing.T) { store := &fakeHeartbeatOrphanRetention{} active := fakeActiveRawAuthKeys{ids: [][8]byte{{3}, {4}}} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, 2*time.Hour, 19). WithOrphanAuthKeyRetention(store, active, 3*time.Hour) w.runRetentionOnce(context.Background()) if store.heartbeatCalls != 1 || store.calls != 1 { t.Fatalf("heartbeat/delete calls = %d/%d, want 1/1", store.heartbeatCalls, store.calls) } if len(store.heartbeatIDs) != 2 || store.heartbeatIDs[0] != ([8]byte{3}) || store.heartbeatIDs[1] != ([8]byte{4}) { t.Fatalf("heartbeat ids = %v, want {3},{4}", store.heartbeatIDs) } // min(retention worker interval=2h, orphan retention/3=1h) if got := w.orphanHeartbeatInterval(); got != time.Hour { t.Fatalf("heartbeat interval = %v, want 1h", got) } } func TestRetentionWorkerSkipsOrphanDeleteWhenHeartbeatFails(t *testing.T) { core, logs := observer.New(zapcore.ErrorLevel) store := &fakeHeartbeatOrphanRetention{heartbeatErr: errors.New("db unavailable")} active := fakeActiveRawAuthKeys{ids: [][8]byte{{5}}} w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.New(core), time.Hour, 30*time.Minute, 11). WithOrphanAuthKeyRetention(store, active, 24*time.Hour) if got := w.orphanHeartbeatInterval(); got != 30*time.Minute { t.Fatalf("heartbeat interval = %v, want worker interval 30m", got) } w.runRetentionOnce(context.Background()) if store.heartbeatCalls != 1 || store.calls != 0 { t.Fatalf("heartbeat/delete calls = %d/%d, want 1/0", store.heartbeatCalls, store.calls) } entries := logs.FilterMessage("刷新 active raw auth key heartbeat 失败,本轮跳过 orphan GC").All() if len(entries) != 1 || entries[0].ContextMap()["signal"] != "auth_key_heartbeat_failed" { t.Fatalf("heartbeat failure signals = %+v", entries) } }