Files

270 lines
9.1 KiB
Go

//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"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// 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")
}
// TestRepo_TryStartPrune_CASLossReturnsAlreadyActive 验证:对一个已变为 active 的
// worktree 调用 TryStartPrune 会返回 CodeWorktreeAlreadyActive。
func TestRepo_TryStartPrune_CASLossReturnsAlreadyActive(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-cas", Name: "CAS",
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: "cas-branch",
Path: "/x/.worktrees/cas", Status: WorktreeStatusIdle,
})
return e
}))
// 绕过 service 直接把 status 改成 active,模拟 CAS 竞争丢失。
_, err = pool.Exec(ctx, `UPDATE workspace_worktrees SET status = 'active' WHERE id = $1`, wtID)
require.NoError(t, err)
_, err = repo.TryStartPrune(ctx, wtID)
require.Error(t, err)
appErr, ok := errs.As(err)
require.True(t, ok, "expected AppError")
require.Equal(t, errs.CodeWorktreeAlreadyActive, appErr.Code)
}
// TestRepo_ListWorktreesNeedingPruneWarning_FiltersExisting 验证:已有
// prune_warning_at 的 worktree 不会被返回,仅返回尚未标记的。
func TestRepo_ListWorktreesNeedingPruneWarning_FiltersExisting(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-pwf", Name: "PWF",
GitRemoteURL: "https://x", DefaultBranch: "main", MainPath: "/x", SyncStatus: SyncStatusIdle,
})
require.NoError(t, err)
cutoff := time.Now().Add(-24 * time.Hour)
// worktree A:没有 prune_warning_at,last_used_at 在 cutoff 之前 → 应返回。
wtA := uuid.New()
require.NoError(t, repo.InTx(ctx, func(tx Tx) error {
_, e := tx.InsertWorktree(ctx, &Worktree{
ID: wtA, WorkspaceID: wsID, Branch: "prune-a",
Path: "/x/.worktrees/a", Status: WorktreeStatusIdle,
})
return e
}))
_, err = pool.Exec(ctx, `UPDATE workspace_worktrees SET last_used_at = $1 WHERE id = $2`, cutoff.Add(-time.Hour), wtA)
require.NoError(t, err)
// worktree B:已经有 prune_warning_at,last_used_at 同样在 cutoff 前 → 不应返回。
wtB := uuid.New()
require.NoError(t, repo.InTx(ctx, func(tx Tx) error {
_, e := tx.InsertWorktree(ctx, &Worktree{
ID: wtB, WorkspaceID: wsID, Branch: "prune-b",
Path: "/x/.worktrees/b", Status: WorktreeStatusIdle,
})
return e
}))
_, err = pool.Exec(ctx,
`UPDATE workspace_worktrees SET last_used_at = $1, prune_warning_at = now() WHERE id = $2`,
cutoff.Add(-time.Hour), wtB)
require.NoError(t, err)
rows, err := repo.ListWorktreesNeedingPruneWarning(ctx, cutoff)
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, wtA, rows[0].ID)
}