//go:build integration package workspace import ( "context" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" tcpg "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/yan1h/agent-coding-workflow/internal/infra/db" ) // setupRepo 起一个临时 postgres 容器、跑迁移、注入一条 user/project 行做外键, // 返回可直接调用的 Repository 与所属 project_id。每个测试用独立容器,互不干扰, // 便于 Parallel。 func setupRepo(t *testing.T) (Repository, *pgxpool.Pool, uuid.UUID) { t.Helper() ctx := context.Background() container, err := tcpg.Run(ctx, "postgres:16-alpine", tcpg.WithDatabase("acw"), tcpg.WithUsername("acw"), tcpg.WithPassword("acw"), ) require.NoError(t, err) t.Cleanup(func() { _ = container.Terminate(ctx) }) dsn, err := container.ConnectionString(ctx, "sslmode=disable") require.NoError(t, err) require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations")) pool, err := db.NewPool(ctx, dsn) require.NoError(t, err) t.Cleanup(pool.Close) uid := uuid.New() _, err = pool.Exec(ctx, `INSERT INTO users (id,email,password_hash,display_name,is_admin,created_at,updated_at) VALUES ($1,'a@b.c','x','admin',true,now(),now())`, uid) require.NoError(t, err) pid := uuid.New() _, err = pool.Exec(ctx, `INSERT INTO projects (id,slug,name,owner_id,visibility) VALUES ($1,'p','P',$2,'private')`, pid, uid) require.NoError(t, err) return NewPostgresRepository(pool), pool, pid } func TestRepo_CreateWorkspaceAndRead(t *testing.T) { t.Parallel() repo, _, pid := setupRepo(t) ctx := context.Background() id := uuid.New() ws, err := repo.CreateWorkspace(ctx, &Workspace{ ID: id, ProjectID: pid, Slug: "ws1", Name: "W1", GitRemoteURL: "https://x", DefaultBranch: "main", MainPath: "/data/ws1/main", SyncStatus: SyncStatusCloning, }) require.NoError(t, err) require.Equal(t, "ws1", ws.Slug) got, err := repo.GetWorkspaceByID(ctx, id) require.NoError(t, err) require.Equal(t, "W1", got.Name) } func TestRepo_SyncStatusRoundtrip(t *testing.T) { t.Parallel() repo, _, pid := setupRepo(t) ctx := context.Background() id := uuid.New() _, err := repo.CreateWorkspace(ctx, &Workspace{ ID: id, ProjectID: pid, Slug: "ws2", Name: "W2", GitRemoteURL: "https://x", DefaultBranch: "main", MainPath: "/x", SyncStatus: SyncStatusCloning, }) require.NoError(t, err) now := time.Now().UTC() _, err = repo.UpdateWorkspaceSyncStatus(ctx, id, SyncStatusError, &now, "boom") require.NoError(t, err) got, err := repo.GetWorkspaceByID(ctx, id) require.NoError(t, err) require.Equal(t, SyncStatusError, got.SyncStatus) require.Equal(t, "boom", got.LastSyncError) } func TestRepo_AcquireRelease_Concurrent(t *testing.T) { t.Parallel() repo, _, pid := setupRepo(t) ctx := context.Background() wsID := uuid.New() _, err := repo.CreateWorkspace(ctx, &Workspace{ ID: wsID, ProjectID: pid, Slug: "ws3", Name: "W3", GitRemoteURL: "https://x", DefaultBranch: "main", MainPath: "/x", SyncStatus: SyncStatusIdle, }) require.NoError(t, err) first := make(chan error, 1) second := make(chan error, 1) holderA, holderB := "user:a", "user:b" go func() { first <- repo.InTx(ctx, func(tx Tx) error { wt, err := tx.GetWorktreeForUpdate(ctx, wsID, "feat") if err == nil && wt.Status == WorktreeStatusActive { return nil } _, err = tx.InsertWorktree(ctx, &Worktree{ ID: uuid.New(), WorkspaceID: wsID, Branch: "feat", Path: "/x/.worktrees/feat", Status: WorktreeStatusActive, ActiveHolder: holderA, }) return err }) }() <-first go func() { second <- repo.InTx(ctx, func(tx Tx) error { wt, err := tx.GetWorktreeForUpdate(ctx, wsID, "feat") if err != nil { return err } if wt.Status == WorktreeStatusActive && wt.ActiveHolder != holderB { return errsAlreadyActive() } return nil }) }() require.Error(t, <-second) } func errsAlreadyActive() error { return &shimErr{} } type shimErr struct{} func (e *shimErr) Error() string { return "already active" } // TestRepo_SetWorktreeActive_ClearsPruneWarning 验证 SetWorktreeActive // 会清空 prune_warning_at 列;同样验证 SetWorktreeIdle。前置:用直接 SQL // 把列写成非 NULL;行为:repo 调用后再读 DB 应回到 NULL。 func TestRepo_SetWorktreeActive_ClearsPruneWarning(t *testing.T) { t.Parallel() repo, pool, pid := setupRepo(t) ctx := context.Background() wsID := uuid.New() _, err := repo.CreateWorkspace(ctx, &Workspace{ ID: wsID, ProjectID: pid, Slug: "ws-pw", Name: "PW", GitRemoteURL: "https://x", DefaultBranch: "main", MainPath: "/x", SyncStatus: SyncStatusIdle, }) require.NoError(t, err) wtID := uuid.New() require.NoError(t, repo.InTx(ctx, func(tx Tx) error { _, e := tx.InsertWorktree(ctx, &Worktree{ ID: wtID, WorkspaceID: wsID, Branch: "warn-branch", Path: "/x/.worktrees/warn", Status: WorktreeStatusIdle, }) return e })) // 把 prune_warning_at 写成非 NULL。 _, err = pool.Exec(ctx, `UPDATE workspace_worktrees SET prune_warning_at = now() WHERE id = $1`, wtID) require.NoError(t, err) var pwAt *time.Time require.NoError(t, pool.QueryRow(ctx, `SELECT prune_warning_at FROM workspace_worktrees WHERE id = $1`, wtID).Scan(&pwAt)) require.NotNil(t, pwAt, "precondition: prune_warning_at should be set") // SetWorktreeActive 应该清空它。 require.NoError(t, repo.InTx(ctx, func(tx Tx) error { _, e := tx.SetWorktreeActive(ctx, wtID, "user:tester") return e })) require.NoError(t, pool.QueryRow(ctx, `SELECT prune_warning_at FROM workspace_worktrees WHERE id = $1`, wtID).Scan(&pwAt)) require.Nil(t, pwAt, "SetWorktreeActive should clear prune_warning_at") // 再写回非 NULL,验证 SetWorktreeIdle 也会清空。 _, err = pool.Exec(ctx, `UPDATE workspace_worktrees SET prune_warning_at = now() WHERE id = $1`, wtID) require.NoError(t, err) require.NoError(t, repo.InTx(ctx, func(tx Tx) error { _, e := tx.SetWorktreeIdle(ctx, wtID) return e })) require.NoError(t, pool.QueryRow(ctx, `SELECT prune_warning_at FROM workspace_worktrees WHERE id = $1`, wtID).Scan(&pwAt)) require.Nil(t, pwAt, "SetWorktreeIdle should clear prune_warning_at") }