You've already forked agentic-coding-workflow
feat(workspace): postgres repository with type conversions and tx
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//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" }
|
||||
Reference in New Issue
Block a user