//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" }