262ecdf270
Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com> Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
364 lines
11 KiB
Go
364 lines
11 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
dbPath := filepath.Join(tmp, "gitlab.db")
|
|
dbConn, err := db.OpenSQLite(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
|
|
t.Cleanup(func() { _ = sqlxDB.Close() })
|
|
if _, err := sqlxDB.Exec(`CREATE TABLE workspaces (
|
|
id TEXT PRIMARY KEY,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE tasks (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL DEFAULT '',
|
|
archived_at DATETIME
|
|
)`); err != nil {
|
|
t.Fatalf("create tasks table: %v", err)
|
|
}
|
|
store, err := NewStore(sqlxDB, sqlxDB)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func seedWorkspace(t *testing.T, store *Store, workspaceID string) {
|
|
t.Helper()
|
|
if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES (?)`, workspaceID); err != nil {
|
|
t.Fatalf("seed workspace %s: %v", workspaceID, err)
|
|
}
|
|
}
|
|
|
|
func seedTask(t *testing.T, store *Store, taskID, workspaceID string) {
|
|
t.Helper()
|
|
if _, err := store.db.Exec(
|
|
`INSERT INTO tasks (id, workspace_id) VALUES (?, ?)`,
|
|
taskID, workspaceID,
|
|
); err != nil {
|
|
t.Fatalf("seed task %s: %v", taskID, err)
|
|
}
|
|
}
|
|
|
|
func newTestMR(taskID, repoID, project string, iid int) *TaskMR {
|
|
return &TaskMR{
|
|
TaskID: taskID,
|
|
RepositoryID: repoID,
|
|
Host: "https://gitlab.com",
|
|
ProjectPath: project,
|
|
MRIID: iid,
|
|
MRURL: "https://gitlab.com/" + project + "/-/merge_requests/1",
|
|
MRTitle: "test MR",
|
|
HeadBranch: "feat/x",
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
func TestStore_UpsertTaskMR_InsertsThenUpdates(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
tm := newTestMR("task-1", "", "acme/api", 42)
|
|
if err := store.UpsertTaskMR(ctx, tm); err != nil {
|
|
t.Fatalf("initial upsert: %v", err)
|
|
}
|
|
if tm.ID == "" {
|
|
t.Fatal("expected ID populated after insert")
|
|
}
|
|
if tm.UpdatedAt.IsZero() {
|
|
t.Fatal("expected UpdatedAt populated after insert")
|
|
}
|
|
originalID := tm.ID
|
|
originalCreated := tm.CreatedAt
|
|
|
|
// Second upsert with new mutable fields — id and created_at must be
|
|
// preserved while title and state get the new values.
|
|
tm.MRTitle = "updated title"
|
|
tm.State = "merged"
|
|
if err := store.UpsertTaskMR(ctx, tm); err != nil {
|
|
t.Fatalf("second upsert: %v", err)
|
|
}
|
|
if tm.ID != originalID {
|
|
t.Errorf("ID changed across upsert: %q -> %q", originalID, tm.ID)
|
|
}
|
|
if !tm.CreatedAt.Equal(originalCreated) {
|
|
t.Errorf("CreatedAt clobbered: %v vs %v", originalCreated, tm.CreatedAt)
|
|
}
|
|
|
|
got, err := store.ListTaskMRsByTask(ctx, "task-1")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("rows = %d, want 1 (upsert must not duplicate)", len(got))
|
|
}
|
|
if got[0].MRTitle != "updated title" || got[0].State != "merged" {
|
|
t.Errorf("title/state not updated: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestStore_UpsertTaskMR_ConcurrentCallsPreserveOriginalIdentity(t *testing.T) {
|
|
store := newTestStore(t)
|
|
const writers = 16
|
|
start := make(chan struct{})
|
|
results := make(chan *TaskMR, writers)
|
|
errs := make(chan error, writers)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < writers; i++ {
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
<-start
|
|
row := newTestMR("task-concurrent", "repo-1", "group/subgroup/project", 7)
|
|
row.ID = fmt.Sprintf("candidate-%02d", index)
|
|
row.CreatedAt = time.Date(2026, 1, 1, 0, 0, index, 0, time.UTC)
|
|
row.MRTitle = fmt.Sprintf("writer-%02d", index)
|
|
errs <- store.UpsertTaskMR(context.Background(), row)
|
|
results <- row
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
close(results)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("concurrent upsert: %v", err)
|
|
}
|
|
}
|
|
rows, err := store.ListTaskMRsByTask(context.Background(), "task-concurrent")
|
|
if err != nil {
|
|
t.Fatalf("list task MRs: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("stored rows = %d, want exactly one", len(rows))
|
|
}
|
|
stored := rows[0]
|
|
for result := range results {
|
|
if result.ID != stored.ID {
|
|
t.Fatalf("caller ID = %q, stored ID = %q", result.ID, stored.ID)
|
|
}
|
|
if !result.CreatedAt.Equal(stored.CreatedAt) {
|
|
t.Fatalf("caller created_at = %v, stored created_at = %v", result.CreatedAt, stored.CreatedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStore_UpsertTaskMR_KeyedByRepoIDAndProjectAndIID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Three rows under the same task, distinguished by repository_id and IID.
|
|
rows := []*TaskMR{
|
|
newTestMR("task-1", "repo-a", "acme/api", 1),
|
|
newTestMR("task-1", "repo-b", "acme/api", 1), // same project + iid, different repo
|
|
newTestMR("task-1", "repo-a", "acme/web", 2), // same repo, different project + iid
|
|
}
|
|
for _, r := range rows {
|
|
if err := store.UpsertTaskMR(ctx, r); err != nil {
|
|
t.Fatalf("upsert %s/%d: %v", r.ProjectPath, r.MRIID, err)
|
|
}
|
|
}
|
|
|
|
got, err := store.ListTaskMRsByTask(ctx, "task-1")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("rows = %d, want 3 (composite key must allow all three)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestStore_ListTaskMRsByWorkspaceID_IsolatesWorkspaces(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
seedTask(t, store, "task-a", "ws-1")
|
|
seedTask(t, store, "task-b", "ws-2")
|
|
if err := store.UpsertTaskMR(ctx, newTestMR("task-a", "", "acme/api", 1)); err != nil {
|
|
t.Fatalf("upsert ws-1 MR: %v", err)
|
|
}
|
|
if err := store.UpsertTaskMR(ctx, newTestMR("task-b", "", "other/proj", 2)); err != nil {
|
|
t.Fatalf("upsert ws-2 MR: %v", err)
|
|
}
|
|
|
|
gotWS1, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("list ws-1: %v", err)
|
|
}
|
|
if len(gotWS1) != 1 || len(gotWS1["task-a"]) != 1 {
|
|
t.Fatalf("ws-1 result = %+v, want one task with one MR", gotWS1)
|
|
}
|
|
if _, leaked := gotWS1["task-b"]; leaked {
|
|
t.Error("ws-1 query leaked task-b from ws-2")
|
|
}
|
|
|
|
gotWS2, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-2")
|
|
if err != nil {
|
|
t.Fatalf("list ws-2: %v", err)
|
|
}
|
|
if len(gotWS2) != 1 || gotWS2["task-b"][0].ProjectPath != "other/proj" {
|
|
t.Errorf("ws-2 result = %+v, want task-b/other/proj", gotWS2)
|
|
}
|
|
|
|
gotEmpty, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("list empty: %v", err)
|
|
}
|
|
if len(gotEmpty) != 0 {
|
|
t.Errorf("empty-workspace result = %+v, want empty map", gotEmpty)
|
|
}
|
|
}
|
|
|
|
func TestStore_ListTaskMRsByWorkspaceID_OrdersByCreatedAtAsc(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", "ws-1")
|
|
|
|
older := newTestMR("task-1", "", "acme/api", 1)
|
|
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := newTestMR("task-1", "", "acme/api", 2)
|
|
newer.CreatedAt = time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Insert newer first to verify ORDER BY is not relying on insertion order.
|
|
if err := store.UpsertTaskMR(ctx, newer); err != nil {
|
|
t.Fatalf("upsert newer: %v", err)
|
|
}
|
|
if err := store.UpsertTaskMR(ctx, older); err != nil {
|
|
t.Fatalf("upsert older: %v", err)
|
|
}
|
|
|
|
got, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
mrs := got["task-1"]
|
|
if len(mrs) != 2 {
|
|
t.Fatalf("rows = %d, want 2", len(mrs))
|
|
}
|
|
if mrs[0].MRIID != 1 || mrs[1].MRIID != 2 {
|
|
t.Errorf("order = [%d, %d], want [1, 2] (created_at ASC)", mrs[0].MRIID, mrs[1].MRIID)
|
|
}
|
|
}
|
|
|
|
func TestStore_ListTaskMRsByTask_ReturnsEmptyForUnknownTask(t *testing.T) {
|
|
store := newTestStore(t)
|
|
got, err := store.ListTaskMRsByTask(context.Background(), "missing-task")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("rows = %d, want 0 for unknown task", len(got))
|
|
}
|
|
}
|
|
|
|
func TestStore_ListTaskMRsByTaskIDs_GroupsByTaskAndPreservesRepositoryIdentity(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
seedWorkspace(t, store, "ws-1")
|
|
seedTask(t, store, "task-1", "ws-1")
|
|
seedTask(t, store, "task-2", "ws-1")
|
|
if err := store.UpsertTaskMR(ctx, newTestMR("task-1", "repo-canonical", "group/project", 224)); err != nil {
|
|
t.Fatalf("upsert task-1 MR: %v", err)
|
|
}
|
|
if err := store.UpsertTaskMR(ctx, newTestMR("task-2", "repo-fork", "fork/project", 224)); err != nil {
|
|
t.Fatalf("upsert task-2 MR: %v", err)
|
|
}
|
|
|
|
got, err := store.ListTaskMRsByTaskIDs(ctx, []string{"task-1", "task-2", "missing"})
|
|
if err != nil {
|
|
t.Fatalf("ListTaskMRsByTaskIDs: %v", err)
|
|
}
|
|
if len(got["task-1"]) != 1 || got["task-1"][0].RepositoryID != "repo-canonical" {
|
|
t.Fatalf("task-1 MRs = %#v, want canonical repo row", got["task-1"])
|
|
}
|
|
if len(got["task-2"]) != 1 || got["task-2"][0].RepositoryID != "repo-fork" {
|
|
t.Fatalf("task-2 MRs = %#v, want fork repo row", got["task-2"])
|
|
}
|
|
if _, ok := got["missing"]; ok {
|
|
t.Fatal("missing task unexpectedly present in grouped results")
|
|
}
|
|
}
|
|
|
|
func TestStore_DeleteTaskMR_RemovesByID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
tm := newTestMR("task-1", "", "acme/api", 1)
|
|
if err := store.UpsertTaskMR(ctx, tm); err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
if err := store.DeleteTaskMR(ctx, tm.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
got, _ := store.ListTaskMRsByTask(ctx, "task-1")
|
|
if len(got) != 0 {
|
|
t.Errorf("rows after delete = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
// TestStore_DeleteTaskMR_CascadesLifecycleCheckpoint is the unlink-cleanup
|
|
// finding: removing an MR link must also remove its lifecycle checkpoint, or
|
|
// re-linking the same MR later would inherit stale observations and could
|
|
// suppress its next lifecycle prompt.
|
|
func TestStore_DeleteTaskMR_CascadesLifecycleCheckpoint(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", "")
|
|
|
|
tm := newTestMR("task-1", "", "acme/api", 1)
|
|
if err := store.UpsertTaskMR(ctx, tm); err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
if err := store.SetTaskMRObservedState(ctx, "task-1", "", "acme/api", 1, "merged"); err != nil {
|
|
t.Fatalf("SetTaskMRObservedState: %v", err)
|
|
}
|
|
|
|
if err := store.DeleteTaskMR(ctx, tm.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
state, err := store.GetTaskMRLifecycleState(ctx, "task-1", "", "acme/api", 1)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskMRLifecycleState: %v", err)
|
|
}
|
|
if state != nil {
|
|
t.Fatalf("expected lifecycle checkpoint removed alongside the MR link, got %+v", state)
|
|
}
|
|
}
|
|
|
|
func TestStore_DeleteTaskMR_UnknownIDIsNoOp(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if err := store.DeleteTaskMR(context.Background(), "no-such-id"); err != nil {
|
|
t.Errorf("delete unknown = %v, want nil (DELETE of 0 rows is not an error)", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_NewStore_RejectsNilDB(t *testing.T) {
|
|
if _, err := NewStore(nil, nil); err == nil {
|
|
t.Fatal("expected error when both db handles are nil")
|
|
}
|
|
}
|