package gitlab import ( "context" "path/filepath" "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") if _, err := sqlxDB.Exec(`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 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_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_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)) } } 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") } }