| package account |
|
|
| import ( |
| "context" |
| "errors" |
| "path/filepath" |
| "strings" |
| "testing" |
| "time" |
|
|
| accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" |
| "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" |
| "github.com/chenyme/grok2api/backend/internal/repository" |
| ) |
|
|
| func TestBatchDeleteWithLinkedRemovesPeersAndKeepsUntargetedWeb(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-linked-delete.db") |
| web, build, console := seedLinkedTrio(t, repo, strings.Repeat("1", 64), "u1") |
|
|
| result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderBuild, []uint64{build.ID}, []accountdomain.Provider{accountdomain.ProviderConsole}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if result.Deleted != 2 || result.LinkedDeleted != 1 { |
| t.Fatalf("result = %#v", result) |
| } |
| assertAccountMissing(t, repo, build.ID) |
| assertAccountMissing(t, repo, console.ID) |
| assertAccountPresent(t, repo, web.ID) |
|
|
| web2 := mustUpsertLinked(t, repo, accountdomain.Credential{ |
| Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web2", SourceKey: "sso:" + strings.Repeat("2", 64), |
| }) |
| deleted, err := service.BatchDelete(ctx, []uint64{web2.ID}) |
| if err != nil || deleted != 1 { |
| t.Fatalf("legacy batch delete deleted=%d err=%v", deleted, err) |
| } |
| } |
|
|
| func TestBatchDeleteWithLinkedWebDeletesBuildAndConsole(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-linked-delete-web.db") |
| web, build, console := seedLinkedTrio(t, repo, strings.Repeat("a", 64), "u-web") |
|
|
| result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if result.Deleted != 3 || result.RootsDeleted != 1 || result.LinkedDeleted != 2 { |
| t.Fatalf("result = %#v", result) |
| } |
| assertAccountMissing(t, repo, web.ID) |
| assertAccountMissing(t, repo, build.ID) |
| assertAccountMissing(t, repo, console.ID) |
| } |
|
|
| func TestBatchDeleteWithLinkedMixedBatchOnlyExpandsLinkedRoots(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-linked-delete-mix.db") |
| webLinked, _, console := seedLinkedTrio(t, repo, strings.Repeat("b", 64), "u-mix") |
| webOnly := mustUpsertLinked(t, repo, accountdomain.Credential{ |
| Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web-only", SourceKey: "sso:" + strings.Repeat("c", 64), |
| }) |
|
|
| result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{webLinked.ID, webOnly.ID}, []accountdomain.Provider{accountdomain.ProviderConsole}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if result.Deleted != 3 { |
| t.Fatalf("result = %#v", result) |
| } |
| assertAccountMissing(t, repo, webLinked.ID) |
| assertAccountMissing(t, repo, webOnly.ID) |
| assertAccountMissing(t, repo, console.ID) |
| } |
|
|
| func TestPreviewLinkedDeleteCountsWithoutDeleting(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-linked-preview.db") |
| web, build, console := seedLinkedTrio(t, repo, strings.Repeat("d", 64), "u-prev") |
|
|
| res, err := service.PreviewLinkedDelete(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderBuild, accountdomain.ProviderConsole}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if len(res.RootIDs) != 1 || res.LinkedByProvider[accountdomain.ProviderBuild] != 1 || res.LinkedByProvider[accountdomain.ProviderConsole] != 1 || len(res.FinalIDs) != 3 { |
| t.Fatalf("preview = %#v", res) |
| } |
| assertAccountPresent(t, repo, web.ID) |
| assertAccountPresent(t, repo, build.ID) |
| assertAccountPresent(t, repo, console.ID) |
| } |
|
|
| func TestBatchDeleteWithLinkedEmptyTargetsDeletesRootOnly(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-linked-empty-targets.db") |
| web, build, console := seedLinkedTrio(t, repo, strings.Repeat("e", 64), "u-empty") |
|
|
| result, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if result.Deleted != 1 || result.LinkedDeleted != 0 { |
| t.Fatalf("result = %#v", result) |
| } |
| assertAccountMissing(t, repo, web.ID) |
| assertAccountPresent(t, repo, build.ID) |
| assertAccountPresent(t, repo, console.ID) |
| } |
|
|
| func TestBatchDeleteWithLinkedInvalidTarget(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-linked-bad-target.db") |
| web, _, _ := seedLinkedTrio(t, repo, strings.Repeat("f", 64), "u-bad") |
|
|
| if _, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.Provider("nope")}); err == nil { |
| t.Fatal("expected invalid target error") |
| } |
| if _, err := service.BatchDeleteWithLinked(ctx, accountdomain.ProviderWeb, []uint64{web.ID}, []accountdomain.Provider{accountdomain.ProviderWeb}); err == nil { |
| t.Fatal("expected self-target error") |
| } |
| assertAccountPresent(t, repo, web.ID) |
| } |
|
|
| func TestDeleteMissingAccountReturnsNotFound(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| _, service := newLinkedDeleteTestService(t, "svc-delete-missing.db") |
|
|
| if err := service.Delete(ctx, 9_999_999); !errors.Is(err, ErrNotFound) { |
| t.Fatalf("Delete missing: err=%v", err) |
| } |
| if _, err := service.DeleteWithLinked(ctx, accountdomain.ProviderWeb, 9_999_999, []accountdomain.Provider{accountdomain.ProviderBuild}); !errors.Is(err, ErrNotFound) { |
| t.Fatalf("DeleteWithLinked missing: err=%v", err) |
| } |
| } |
|
|
| func TestDeleteWithLinkedRejectsRootFromAnotherProvider(t *testing.T) { |
| t.Parallel() |
| ctx := context.Background() |
| repo, service := newLinkedDeleteTestService(t, "svc-delete-provider-mismatch.db") |
| web, _, _ := seedLinkedTrio(t, repo, strings.Repeat("9", 64), "u-provider-mismatch") |
|
|
| _, err := service.DeleteWithLinked(ctx, accountdomain.ProviderBuild, web.ID, []accountdomain.Provider{accountdomain.ProviderConsole}) |
| if !errors.Is(err, ErrConflict) { |
| t.Fatalf("provider mismatch error = %v", err) |
| } |
| assertAccountPresent(t, repo, web.ID) |
| } |
|
|
| func TestFinishLinkedDeleteUsesBatchStickyCleanup(t *testing.T) { |
| sticky := &stickyBatchStub{} |
| service := &Service{sticky: sticky} |
| service.finishLinkedDelete(context.Background(), []uint64{3, 5, 8}) |
| if sticky.singleCalls != 0 { |
| t.Fatalf("single-account cleanup calls = %d", sticky.singleCalls) |
| } |
| if len(sticky.batchCalls) != 1 || len(sticky.batchCalls[0]) != 3 || sticky.batchCalls[0][0] != 3 || sticky.batchCalls[0][2] != 8 { |
| t.Fatalf("batch cleanup calls = %#v", sticky.batchCalls) |
| } |
| } |
|
|
| func newLinkedDeleteTestService(t *testing.T, dbName string) (*relational.AccountRepository, *Service) { |
| t.Helper() |
| ctx := context.Background() |
| database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), dbName)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| t.Cleanup(func() { _ = database.Close() }) |
| if err := database.InitializeSchema(ctx); err != nil { |
| t.Fatal(err) |
| } |
| repo := relational.NewAccountRepository(database) |
| return repo, &Service{accounts: repo, sticky: stickyStub{}, logger: nil} |
| } |
|
|
| func seedLinkedTrio(t *testing.T, repo *relational.AccountRepository, digest, userID string) (web, build, console accountdomain.Credential) { |
| t.Helper() |
| ctx := context.Background() |
| web = mustUpsertLinked(t, repo, accountdomain.Credential{ |
| Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + digest, UserID: userID, |
| }) |
| build = mustUpsertLinked(t, repo, accountdomain.Credential{ |
| Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", SourceKey: "build-" + digest[:8], UserID: userID, |
| }) |
| console = mustUpsertLinked(t, repo, accountdomain.Credential{ |
| Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, Name: "console", SourceKey: "console-sso:" + digest, UserID: userID, |
| }) |
| if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { |
| t.Fatal(err) |
| } |
| if err := repo.ReconcileProviderLinks(ctx, web.ID); err != nil { |
| t.Fatal(err) |
| } |
| return web, build, console |
| } |
|
|
| func mustUpsertLinked(t *testing.T, repo *relational.AccountRepository, value accountdomain.Credential) accountdomain.Credential { |
| t.Helper() |
| value.EncryptedAccessToken = "encrypted" |
| value.Enabled = true |
| value.AuthStatus = accountdomain.AuthStatusActive |
| value.Priority = accountdomain.DefaultPriority |
| value.MaxConcurrent = accountdomain.DefaultMaxConcurrent |
| stored, _, err := repo.UpsertByIdentity(context.Background(), value) |
| if err != nil { |
| t.Fatal(err) |
| } |
| return stored |
| } |
|
|
| func assertAccountMissing(t *testing.T, repo *relational.AccountRepository, id uint64) { |
| t.Helper() |
| if _, err := repo.Get(context.Background(), id); !errors.Is(err, repository.ErrNotFound) { |
| t.Fatalf("account %d should be missing, err=%v", id, err) |
| } |
| } |
|
|
| func assertAccountPresent(t *testing.T, repo *relational.AccountRepository, id uint64) { |
| t.Helper() |
| if _, err := repo.Get(context.Background(), id); err != nil { |
| t.Fatalf("account %d should remain: %v", id, err) |
| } |
| } |
|
|
| type stickyStub struct{} |
|
|
| func (stickyStub) Get(context.Context, string, time.Time) (uint64, bool, error) { |
| return 0, false, nil |
| } |
| func (stickyStub) Bind(context.Context, string, uint64, time.Time, time.Time) (uint64, error) { |
| return 0, nil |
| } |
| func (stickyStub) Set(context.Context, string, uint64, time.Time) error { return nil } |
| func (stickyStub) DeleteByAccount(context.Context, uint64) error { return nil } |
|
|
| type stickyBatchStub struct { |
| singleCalls int |
| batchCalls [][]uint64 |
| } |
|
|
| func (*stickyBatchStub) Get(context.Context, string, time.Time) (uint64, bool, error) { |
| return 0, false, nil |
| } |
| func (*stickyBatchStub) Bind(context.Context, string, uint64, time.Time, time.Time) (uint64, error) { |
| return 0, nil |
| } |
| func (*stickyBatchStub) Set(context.Context, string, uint64, time.Time) error { return nil } |
| func (s *stickyBatchStub) DeleteByAccount(context.Context, uint64) error { |
| s.singleCalls++ |
| return nil |
| } |
| func (s *stickyBatchStub) DeleteByAccounts(_ context.Context, ids []uint64) error { |
| s.batchCalls = append(s.batchCalls, append([]uint64(nil), ids...)) |
| return nil |
| } |
|
|