Files
kandev/apps/backend/internal/task/repository/sqlite/plan_test.go
T
João Salavisa a6a26911b7 test(backend): cover the untested sqlite task-repository surfaces (#2490)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:14:29 +01:00

834 lines
30 KiB
Go

package sqlite
//revive:disable:file-length-limit // Task plan HEAD + revision persistence coverage is intentionally scenario-heavy.
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func assertTaskPlanEqual(t *testing.T, got, want *models.TaskPlan) {
t.Helper()
if got == nil {
t.Fatal("plan is nil")
}
if got.ID != want.ID {
t.Errorf("ID = %q, want %q", got.ID, want.ID)
}
if got.TaskID != want.TaskID {
t.Errorf("TaskID = %q, want %q", got.TaskID, want.TaskID)
}
if got.Title != want.Title {
t.Errorf("Title = %q, want %q", got.Title, want.Title)
}
if got.Content != want.Content {
t.Errorf("Content = %q, want %q", got.Content, want.Content)
}
if got.CreatedBy != want.CreatedBy {
t.Errorf("CreatedBy = %q, want %q", got.CreatedBy, want.CreatedBy)
}
assertTimeEqual(t, "CreatedAt", got.CreatedAt, want.CreatedAt)
assertTimeEqual(t, "UpdatedAt", got.UpdatedAt, want.UpdatedAt)
assertOptionalString(t, "ImplementationStartedSessionID", got.ImplementationStartedSessionID, want.ImplementationStartedSessionID)
assertOptionalString(t, "ImplementationStartedBy", got.ImplementationStartedBy, want.ImplementationStartedBy)
switch {
case want.ImplementationStartedAt == nil && got.ImplementationStartedAt != nil:
t.Errorf("ImplementationStartedAt = %v, want nil", *got.ImplementationStartedAt)
case want.ImplementationStartedAt != nil && got.ImplementationStartedAt == nil:
t.Errorf("ImplementationStartedAt = nil, want %v", *want.ImplementationStartedAt)
case want.ImplementationStartedAt != nil && !got.ImplementationStartedAt.Truncate(time.Microsecond).Equal(want.ImplementationStartedAt.Truncate(time.Microsecond)):
t.Errorf("ImplementationStartedAt = %v, want %v", *got.ImplementationStartedAt, *want.ImplementationStartedAt)
}
}
func assertOptionalString(t *testing.T, label string, got, want *string) {
t.Helper()
switch {
case want == nil && got != nil:
t.Errorf("%s = %q, want nil", label, *got)
case want != nil && got == nil:
t.Errorf("%s = nil, want %q", label, *want)
case want != nil && *got != *want:
t.Errorf("%s = %q, want %q", label, *got, *want)
}
}
func assertPlanRevisionEqual(t *testing.T, got, want *models.TaskPlanRevision) {
t.Helper()
if got == nil {
t.Fatal("revision is nil")
}
if got.ID != want.ID {
t.Errorf("ID = %q, want %q", got.ID, want.ID)
}
if got.TaskID != want.TaskID {
t.Errorf("TaskID = %q, want %q", got.TaskID, want.TaskID)
}
if got.RevisionNumber != want.RevisionNumber {
t.Errorf("RevisionNumber = %d, want %d", got.RevisionNumber, want.RevisionNumber)
}
if got.Title != want.Title {
t.Errorf("Title = %q, want %q", got.Title, want.Title)
}
if got.Content != want.Content {
t.Errorf("Content = %q, want %q", got.Content, want.Content)
}
if got.AuthorKind != want.AuthorKind {
t.Errorf("AuthorKind = %q, want %q", got.AuthorKind, want.AuthorKind)
}
if got.AuthorName != want.AuthorName {
t.Errorf("AuthorName = %q, want %q", got.AuthorName, want.AuthorName)
}
assertOptionalString(t, "RevertOfRevisionID", got.RevertOfRevisionID, want.RevertOfRevisionID)
assertTimeEqual(t, "CreatedAt", got.CreatedAt, want.CreatedAt)
assertTimeEqual(t, "UpdatedAt", got.UpdatedAt, want.UpdatedAt)
}
func TestCreateTaskPlanRoundTripsEveryField(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-full")
before := time.Now().UTC().Add(-time.Second)
want := &models.TaskPlan{
ID: "plan-full", TaskID: "task-plan-full",
Title: "Implementation plan", Content: "## Steps\n1. do it", CreatedBy: "user",
}
if err := repo.CreateTaskPlan(ctx, want); err != nil {
t.Fatalf("CreateTaskPlan: %v", err)
}
if want.CreatedAt.Before(before) || !want.CreatedAt.Equal(want.UpdatedAt) {
t.Fatalf("CreateTaskPlan stamped CreatedAt=%v UpdatedAt=%v, want equal times after %v",
want.CreatedAt, want.UpdatedAt, before)
}
got, err := repo.GetTaskPlan(ctx, "task-plan-full")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
assertTaskPlanEqual(t, got, want)
}
func TestCreateTaskPlanAppliesDefaultsForEmptyFields(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-defaults")
plan := &models.TaskPlan{TaskID: "task-plan-defaults", Content: "body"}
if err := repo.CreateTaskPlan(ctx, plan); err != nil {
t.Fatalf("CreateTaskPlan: %v", err)
}
if plan.ID == "" {
t.Fatal("ID left empty; a UUID should have been generated")
}
if plan.Title != "Plan" {
t.Errorf("Title = %q, want the %q default", plan.Title, "Plan")
}
if plan.CreatedBy != authorKindAgent {
t.Errorf("CreatedBy = %q, want the %q default", plan.CreatedBy, authorKindAgent)
}
got, err := repo.GetTaskPlan(ctx, "task-plan-defaults")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
if got.Title != "Plan" || got.CreatedBy != authorKindAgent {
t.Errorf("persisted defaults = %q/%q, want Plan/%s", got.Title, got.CreatedBy, authorKindAgent)
}
if got.ImplementationStartedAt != nil || got.ImplementationStartedSessionID != nil || got.ImplementationStartedBy != nil {
t.Errorf("implementation markers = %+v, want all nil on a fresh plan", got)
}
}
func TestCreateTaskPlanRejectsSecondPlanForSameTask(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-unique")
if err := repo.CreateTaskPlan(ctx, &models.TaskPlan{
ID: "plan-unique-1", TaskID: "task-plan-unique", Title: "One", Content: "one",
}); err != nil {
t.Fatalf("CreateTaskPlan(first): %v", err)
}
if err := repo.CreateTaskPlan(ctx, &models.TaskPlan{
ID: "plan-unique-2", TaskID: "task-plan-unique", Title: "Two", Content: "two",
}); err == nil {
t.Fatal("CreateTaskPlan accepted a second plan for the same task; task_id is UNIQUE")
}
got, err := repo.GetTaskPlan(ctx, "task-plan-unique")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
if got.Title != "One" {
t.Errorf("Title = %q, want the original %q", got.Title, "One")
}
}
func TestGetTaskPlanReturnsNilNilWhenMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
got, err := repo.GetTaskPlan(context.Background(), "task-plan-missing")
if err != nil {
t.Fatalf("GetTaskPlan returned error %v, want the nil,nil not-found contract", err)
}
if got != nil {
t.Errorf("GetTaskPlan = %+v, want nil", got)
}
}
func TestUpdateTaskPlanWritesMutableFieldsAndReportsMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-update")
plan := &models.TaskPlan{
ID: "plan-update", TaskID: "task-plan-update", Title: "Before", Content: "old", CreatedBy: "agent",
}
if err := repo.CreateTaskPlan(ctx, plan); err != nil {
t.Fatalf("CreateTaskPlan: %v", err)
}
createdAt := plan.CreatedAt
plan.Title = "After"
plan.Content = "new"
plan.CreatedBy = "user"
if err := repo.UpdateTaskPlan(ctx, plan); err != nil {
t.Fatalf("UpdateTaskPlan: %v", err)
}
got, err := repo.GetTaskPlan(ctx, "task-plan-update")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
assertTaskPlanEqual(t, got, plan)
if !got.CreatedAt.Equal(createdAt) {
t.Errorf("CreatedAt = %v, want it preserved at %v", got.CreatedAt, createdAt)
}
err = repo.UpdateTaskPlan(ctx, &models.TaskPlan{TaskID: "task-plan-nowhere", Title: "x"})
if err == nil {
t.Fatal("UpdateTaskPlan on a missing row returned nil")
}
if !strings.Contains(err.Error(), "task plan not found") {
t.Errorf("UpdateTaskPlan error = %q, want a task-plan-not-found message", err)
}
}
func TestDeleteTaskPlanRemovesRowAndReportsMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-delete")
seedTaskForDocs(t, repo, "task-plan-keep")
for _, taskID := range []string{"task-plan-delete", "task-plan-keep"} {
if err := repo.CreateTaskPlan(ctx, &models.TaskPlan{
ID: "plan-" + taskID, TaskID: taskID, Title: "Plan", Content: taskID,
}); err != nil {
t.Fatalf("CreateTaskPlan(%s): %v", taskID, err)
}
}
if err := repo.DeleteTaskPlan(ctx, "task-plan-delete"); err != nil {
t.Fatalf("DeleteTaskPlan: %v", err)
}
if got := countRows(t, repo, `SELECT COUNT(1) FROM task_plans WHERE task_id = ?`, "task-plan-delete"); got != 0 {
t.Errorf("rows after delete = %d, want 0", got)
}
if got := countRows(t, repo, `SELECT COUNT(1) FROM task_plans WHERE task_id = ?`, "task-plan-keep"); got != 1 {
t.Errorf("other task's plan rows = %d, want 1 (delete must be task-scoped)", got)
}
err := repo.DeleteTaskPlan(ctx, "task-plan-delete")
if err == nil {
t.Fatal("second DeleteTaskPlan returned nil; a missing row must be reported")
}
if !strings.Contains(err.Error(), "task plan not found") {
t.Errorf("DeleteTaskPlan error = %q, want a task-plan-not-found message", err)
}
}
func TestMarkTaskPlanImplementationStartedIsIdempotent(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-mark")
plan := &models.TaskPlan{ID: "plan-mark", TaskID: "task-plan-mark", Title: "Plan", Content: "body"}
if err := repo.CreateTaskPlan(ctx, plan); err != nil {
t.Fatalf("CreateTaskPlan: %v", err)
}
createdUpdatedAt := plan.UpdatedAt
marked, err := repo.MarkTaskPlanImplementationStarted(ctx, "task-plan-mark", "session-first", "jcfs")
if err != nil {
t.Fatalf("MarkTaskPlanImplementationStarted: %v", err)
}
if marked.ImplementationStartedAt == nil {
t.Fatal("ImplementationStartedAt = nil, want the first-start marker set")
}
assertOptionalString(t, "ImplementationStartedSessionID", marked.ImplementationStartedSessionID, strptr("session-first"))
assertOptionalString(t, "ImplementationStartedBy", marked.ImplementationStartedBy, strptr("jcfs"))
if !marked.UpdatedAt.After(createdUpdatedAt) {
t.Errorf("UpdatedAt = %v, want it bumped past %v on the first mark", marked.UpdatedAt, createdUpdatedAt)
}
firstStartedAt := *marked.ImplementationStartedAt
firstUpdatedAt := marked.UpdatedAt
second, err := repo.MarkTaskPlanImplementationStarted(ctx, "task-plan-mark", "session-second", "someone-else")
if err != nil {
t.Fatalf("second MarkTaskPlanImplementationStarted: %v", err)
}
if !second.ImplementationStartedAt.Equal(firstStartedAt) {
t.Errorf("ImplementationStartedAt = %v, want the first value %v preserved", *second.ImplementationStartedAt, firstStartedAt)
}
assertOptionalString(t, "ImplementationStartedSessionID", second.ImplementationStartedSessionID, strptr("session-first"))
assertOptionalString(t, "ImplementationStartedBy", second.ImplementationStartedBy, strptr("jcfs"))
if !second.UpdatedAt.Equal(firstUpdatedAt) {
t.Errorf("UpdatedAt = %v, want it unchanged at %v on a repeat mark", second.UpdatedAt, firstUpdatedAt)
}
}
func TestMarkTaskPlanImplementationStartedReturnsSentinelWhenPlanMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
got, err := repo.MarkTaskPlanImplementationStarted(context.Background(), "task-plan-absent", "session", "actor")
if got != nil {
t.Errorf("plan = %+v, want nil", got)
}
if !errors.Is(err, ErrTaskPlanNotFound) {
t.Fatalf("error = %v, want ErrTaskPlanNotFound", err)
}
if !strings.Contains(err.Error(), "task-plan-absent") {
t.Errorf("error = %q, want the task id included for diagnostics", err)
}
}
func TestInsertTaskPlanRevisionRoundTripsEveryField(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-planrev-full")
revertOf := "rev-earlier"
want := &models.TaskPlanRevision{
ID: "planrev-full", TaskID: "task-planrev-full", RevisionNumber: 4,
Title: "Plan v4", Content: "four", AuthorKind: "user", AuthorName: "jcfs",
RevertOfRevisionID: &revertOf,
CreatedAt: time.Date(2026, 6, 6, 6, 6, 6, 789000000, time.UTC),
}
if err := repo.InsertTaskPlanRevision(ctx, want); err != nil {
t.Fatalf("InsertTaskPlanRevision: %v", err)
}
got, err := repo.GetTaskPlanRevision(ctx, "planrev-full")
if err != nil {
t.Fatalf("GetTaskPlanRevision: %v", err)
}
assertPlanRevisionEqual(t, got, want)
latest, err := repo.GetLatestTaskPlanRevision(ctx, "task-planrev-full")
if err != nil {
t.Fatalf("GetLatestTaskPlanRevision: %v", err)
}
assertPlanRevisionEqual(t, latest, want)
listed, err := repo.ListTaskPlanRevisions(ctx, "task-planrev-full", 0)
if err != nil {
t.Fatalf("ListTaskPlanRevisions: %v", err)
}
if len(listed) != 1 {
t.Fatalf("ListTaskPlanRevisions returned %d rows, want 1", len(listed))
}
assertPlanRevisionEqual(t, listed[0], want)
}
func TestInsertTaskPlanRevisionDefaultsIDAndCreatedAt(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-planrev-defaults")
before := time.Now().UTC().Add(-time.Second)
rev := &models.TaskPlanRevision{
TaskID: "task-planrev-defaults", RevisionNumber: 1, Title: "Plan", Content: "v1", AuthorKind: "agent",
}
if err := repo.InsertTaskPlanRevision(ctx, rev); err != nil {
t.Fatalf("InsertTaskPlanRevision: %v", err)
}
if rev.ID == "" {
t.Fatal("ID left empty; a UUID should have been generated")
}
if rev.CreatedAt.Before(before) {
t.Errorf("CreatedAt = %v, want a freshly stamped time after %v", rev.CreatedAt, before)
}
got, err := repo.GetTaskPlanRevision(ctx, rev.ID)
if err != nil {
t.Fatalf("GetTaskPlanRevision: %v", err)
}
if got.RevertOfRevisionID != nil {
t.Errorf("RevertOfRevisionID = %q, want nil for a NULL column", *got.RevertOfRevisionID)
}
}
func TestInsertTaskPlanRevisionRejectsDuplicateRevisionNumber(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-planrev-dupe")
if err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
ID: "planrev-dupe-1", TaskID: "task-planrev-dupe", RevisionNumber: 1,
Title: "Plan", Content: "one", AuthorKind: "agent",
}); err != nil {
t.Fatalf("InsertTaskPlanRevision(first): %v", err)
}
err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
ID: "planrev-dupe-2", TaskID: "task-planrev-dupe", RevisionNumber: 1,
Title: "Plan", Content: "two", AuthorKind: "agent",
})
if err == nil {
t.Fatal("InsertTaskPlanRevision accepted a duplicate (task_id, revision_number)")
}
if got := countRows(t, repo,
`SELECT COUNT(1) FROM task_plan_revisions WHERE task_id = ?`, "task-planrev-dupe"); got != 1 {
t.Errorf("rows = %d, want 1 (the duplicate must not land)", got)
}
}
func TestUpdateTaskPlanRevisionMergesTitleAndContentOnly(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-planrev-update")
rev := &models.TaskPlanRevision{
ID: "planrev-update", TaskID: "task-planrev-update", RevisionNumber: 2,
Title: "Before", Content: "old", AuthorKind: "agent", AuthorName: "claude",
CreatedAt: time.Date(2026, 1, 1, 1, 1, 1, 0, time.UTC),
}
if err := repo.InsertTaskPlanRevision(ctx, rev); err != nil {
t.Fatalf("InsertTaskPlanRevision: %v", err)
}
rev.Title = "After"
rev.Content = "new"
rev.AuthorKind = "user"
rev.AuthorName = "ignored"
if err := repo.UpdateTaskPlanRevision(ctx, rev); err != nil {
t.Fatalf("UpdateTaskPlanRevision: %v", err)
}
got, err := repo.GetTaskPlanRevision(ctx, "planrev-update")
if err != nil {
t.Fatalf("GetTaskPlanRevision: %v", err)
}
if got.Title != "After" || got.Content != "new" {
t.Errorf("merged revision = %q/%q, want After/new", got.Title, got.Content)
}
if got.AuthorKind != "agent" || got.AuthorName != "claude" {
t.Errorf("author = %q/%q, want the stored agent/claude — the UPDATE must not touch author columns",
got.AuthorKind, got.AuthorName)
}
if got.RevisionNumber != 2 {
t.Errorf("RevisionNumber = %d, want 2 preserved", got.RevisionNumber)
}
if !got.CreatedAt.Equal(time.Date(2026, 1, 1, 1, 1, 1, 0, time.UTC)) {
t.Errorf("CreatedAt = %v, want it preserved", got.CreatedAt)
}
if !got.UpdatedAt.Equal(rev.UpdatedAt) {
t.Errorf("UpdatedAt = %v, want the refreshed %v", got.UpdatedAt, rev.UpdatedAt)
}
err = repo.UpdateTaskPlanRevision(ctx, &models.TaskPlanRevision{ID: "planrev-nowhere", Title: "x"})
if err == nil {
t.Fatal("UpdateTaskPlanRevision on a missing row returned nil")
}
if !strings.Contains(err.Error(), "task plan revision not found") {
t.Errorf("error = %q, want a task-plan-revision-not-found message", err)
}
}
func TestGetTaskPlanRevisionReturnsNilNilWhenMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
got, err := repo.GetTaskPlanRevision(ctx, "planrev-missing")
if err != nil {
t.Fatalf("GetTaskPlanRevision returned error %v, want nil,nil", err)
}
if got != nil {
t.Errorf("GetTaskPlanRevision = %+v, want nil", got)
}
latest, err := repo.GetLatestTaskPlanRevision(ctx, "task-planrev-missing")
if err != nil {
t.Fatalf("GetLatestTaskPlanRevision returned error %v, want nil,nil", err)
}
if latest != nil {
t.Errorf("GetLatestTaskPlanRevision = %+v, want nil", latest)
}
}
func TestListTaskPlanRevisionsOrdersDescendingAndHonoursLimit(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-planrev-list")
seedTaskForDocs(t, repo, "task-planrev-noise")
for number := 1; number <= 3; number++ {
if err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
ID: "planrev-list-" + string(rune('0'+number)), TaskID: "task-planrev-list",
RevisionNumber: number, Title: "Plan", Content: "v", AuthorKind: "agent",
}); err != nil {
t.Fatalf("InsertTaskPlanRevision(%d): %v", number, err)
}
}
if err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
ID: "planrev-noise", TaskID: "task-planrev-noise", RevisionNumber: 99,
Title: "Plan", Content: "noise", AuthorKind: "agent",
}); err != nil {
t.Fatalf("InsertTaskPlanRevision(noise): %v", err)
}
all, err := repo.ListTaskPlanRevisions(ctx, "task-planrev-list", 0)
if err != nil {
t.Fatalf("ListTaskPlanRevisions(limit=0): %v", err)
}
if len(all) != 3 {
t.Fatalf("limit=0 returned %d rows, want 3", len(all))
}
for i, wantNumber := range []int{3, 2, 1} {
if all[i].RevisionNumber != wantNumber {
t.Fatalf("revision numbers = %d at index %d, want %d (newest first)", all[i].RevisionNumber, i, wantNumber)
}
}
limited, err := repo.ListTaskPlanRevisions(ctx, "task-planrev-list", 1)
if err != nil {
t.Fatalf("ListTaskPlanRevisions(limit=1): %v", err)
}
if len(limited) != 1 || limited[0].RevisionNumber != 3 {
t.Errorf("limit=1 returned %+v, want only revision 3", limited)
}
empty, err := repo.ListTaskPlanRevisions(ctx, "task-planrev-nothing", 0)
if err != nil {
t.Fatalf("ListTaskPlanRevisions(unknown task): %v", err)
}
if len(empty) != 0 {
t.Errorf("unknown task returned %+v, want empty", empty)
}
}
func TestNextTaskPlanRevisionNumber(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-planrev-next")
next, err := repo.NextTaskPlanRevisionNumber(ctx, "task-planrev-next")
if err != nil {
t.Fatalf("NextTaskPlanRevisionNumber: %v", err)
}
if next != 1 {
t.Errorf("NextTaskPlanRevisionNumber with no history = %d, want 1", next)
}
if err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
ID: "planrev-next-7", TaskID: "task-planrev-next", RevisionNumber: 7,
Title: "Plan", Content: "v7", AuthorKind: "agent",
}); err != nil {
t.Fatalf("InsertTaskPlanRevision: %v", err)
}
next, err = repo.NextTaskPlanRevisionNumber(ctx, "task-planrev-next")
if err != nil {
t.Fatalf("NextTaskPlanRevisionNumber: %v", err)
}
if next != 8 {
t.Errorf("NextTaskPlanRevisionNumber = %d, want 8 (MAX(7)+1)", next)
}
}
func TestWritePlanRevisionCreatesHeadAndFirstRevision(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-writeplan")
head := &models.TaskPlan{TaskID: "task-writeplan", Content: "first"}
rev := &models.TaskPlanRevision{
TaskID: "task-writeplan", Title: "Plan", Content: "first", AuthorKind: "agent", AuthorName: "claude",
}
if err := repo.WritePlanRevision(ctx, head, rev, nil); err != nil {
t.Fatalf("WritePlanRevision: %v", err)
}
if head.ID == "" {
t.Fatal("head ID left empty; a UUID should have been generated")
}
if head.Title != "Plan" || head.CreatedBy != authorKindAgent {
t.Errorf("head defaults = %q/%q, want Plan/%s", head.Title, head.CreatedBy, authorKindAgent)
}
if rev.RevisionNumber != 1 {
t.Errorf("RevisionNumber = %d, want 1", rev.RevisionNumber)
}
gotHead, err := repo.GetTaskPlan(ctx, "task-writeplan")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
assertTaskPlanEqual(t, gotHead, head)
gotRev, err := repo.GetLatestTaskPlanRevision(ctx, "task-writeplan")
if err != nil {
t.Fatalf("GetLatestTaskPlanRevision: %v", err)
}
assertPlanRevisionEqual(t, gotRev, rev)
}
func TestWritePlanRevisionUpsertsHeadAndAppendsRevisions(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-writeplan-append")
head := &models.TaskPlan{
ID: "plan-append", TaskID: "task-writeplan-append", Title: "v1", Content: "one", CreatedBy: "agent",
}
if err := repo.WritePlanRevision(ctx, head, &models.TaskPlanRevision{
TaskID: "task-writeplan-append", Title: "v1", Content: "one", AuthorKind: "agent",
}, nil); err != nil {
t.Fatalf("WritePlanRevision(first): %v", err)
}
createdAt := head.CreatedAt
head.Title = "v2"
head.Content = "two"
head.CreatedBy = "user"
second := &models.TaskPlanRevision{
TaskID: "task-writeplan-append", Title: "v2", Content: "two", AuthorKind: "user", AuthorName: "jcfs",
}
if err := repo.WritePlanRevision(ctx, head, second, nil); err != nil {
t.Fatalf("WritePlanRevision(second): %v", err)
}
if second.RevisionNumber != 2 {
t.Errorf("second RevisionNumber = %d, want 2", second.RevisionNumber)
}
if got := countRows(t, repo,
`SELECT COUNT(1) FROM task_plans WHERE task_id = ?`, "task-writeplan-append"); got != 1 {
t.Errorf("HEAD rows = %d, want 1 (the upsert must not insert a second row)", got)
}
gotHead, err := repo.GetTaskPlan(ctx, "task-writeplan-append")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
if gotHead.ID != "plan-append" {
t.Errorf("HEAD id = %q, want plan-append", gotHead.ID)
}
if gotHead.Title != "v2" || gotHead.Content != "two" || gotHead.CreatedBy != "user" {
t.Errorf("HEAD = %+v, want the v2 values", gotHead)
}
if !gotHead.CreatedAt.Equal(createdAt) {
t.Errorf("HEAD CreatedAt = %v, want it preserved at %v", gotHead.CreatedAt, createdAt)
}
history, err := repo.ListTaskPlanRevisions(ctx, "task-writeplan-append", 0)
if err != nil {
t.Fatalf("ListTaskPlanRevisions: %v", err)
}
if len(history) != 2 {
t.Fatalf("history has %d revisions, want 2", len(history))
}
if history[0].RevisionNumber != 2 || history[0].Content != "two" || history[0].AuthorName != "jcfs" {
t.Errorf("newest revision = %+v, want revision 2 by jcfs", history[0])
}
if history[1].RevisionNumber != 1 || history[1].Content != "one" {
t.Errorf("oldest revision = %+v, want revision 1 with the original content", history[1])
}
}
func TestWritePlanRevisionPreservesImplementationMarkerAcrossUpsert(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-writeplan-marker")
head := &models.TaskPlan{ID: "plan-marker", TaskID: "task-writeplan-marker", Title: "v1", Content: "one"}
if err := repo.WritePlanRevision(ctx, head, &models.TaskPlanRevision{
TaskID: "task-writeplan-marker", Title: "v1", Content: "one", AuthorKind: "agent",
}, nil); err != nil {
t.Fatalf("WritePlanRevision(first): %v", err)
}
if _, err := repo.MarkTaskPlanImplementationStarted(ctx, "task-writeplan-marker", "session-x", "jcfs"); err != nil {
t.Fatalf("MarkTaskPlanImplementationStarted: %v", err)
}
head.Title = "v2"
head.Content = "two"
if err := repo.WritePlanRevision(ctx, head, &models.TaskPlanRevision{
TaskID: "task-writeplan-marker", Title: "v2", Content: "two", AuthorKind: "agent",
}, nil); err != nil {
t.Fatalf("WritePlanRevision(second): %v", err)
}
got, err := repo.GetTaskPlan(ctx, "task-writeplan-marker")
if err != nil {
t.Fatalf("GetTaskPlan: %v", err)
}
if got.ImplementationStartedAt == nil {
t.Fatal("ImplementationStartedAt = nil; the ON CONFLICT clause must not clear the marker")
}
assertOptionalString(t, "ImplementationStartedSessionID", got.ImplementationStartedSessionID, strptr("session-x"))
assertOptionalString(t, "ImplementationStartedBy", got.ImplementationStartedBy, strptr("jcfs"))
}
func TestWritePlanRevisionCoalescesIntoExistingRevision(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-writeplan-merge")
head := &models.TaskPlan{ID: "plan-merge", TaskID: "task-writeplan-merge", Title: "v1", Content: "one"}
first := &models.TaskPlanRevision{
ID: "planrev-merge", TaskID: "task-writeplan-merge", Title: "v1", Content: "one",
AuthorKind: "agent", AuthorName: "claude",
CreatedAt: time.Date(2026, 2, 2, 2, 2, 2, 0, time.UTC),
}
if err := repo.WritePlanRevision(ctx, head, first, nil); err != nil {
t.Fatalf("WritePlanRevision(first): %v", err)
}
head.Title = "v1 edited"
head.Content = "one edited"
merged := &models.TaskPlanRevision{
TaskID: "task-writeplan-merge", Title: "v1 edited", Content: "one edited",
AuthorKind: "ignored", AuthorName: "ignored",
}
coalesceID := "planrev-merge"
if err := repo.WritePlanRevision(ctx, head, merged, &coalesceID); err != nil {
t.Fatalf("WritePlanRevision(merge): %v", err)
}
if merged.ID != "planrev-merge" {
t.Errorf("merged ID = %q, want planrev-merge", merged.ID)
}
history, err := repo.ListTaskPlanRevisions(ctx, "task-writeplan-merge", 0)
if err != nil {
t.Fatalf("ListTaskPlanRevisions: %v", err)
}
if len(history) != 1 {
t.Fatalf("history has %d revisions, want 1 (coalesce must merge, not append)", len(history))
}
got := history[0]
if got.Title != "v1 edited" || got.Content != "one edited" {
t.Errorf("merged revision = %+v, want the edited title/content", got)
}
if got.RevisionNumber != 1 {
t.Errorf("RevisionNumber = %d, want 1 preserved", got.RevisionNumber)
}
if got.AuthorKind != "agent" || got.AuthorName != "claude" {
t.Errorf("author = %q/%q, want the original agent/claude preserved", got.AuthorKind, got.AuthorName)
}
if !got.CreatedAt.Equal(first.CreatedAt) {
t.Errorf("CreatedAt = %v, want %v preserved", got.CreatedAt, first.CreatedAt)
}
if !got.UpdatedAt.After(got.CreatedAt) {
t.Errorf("UpdatedAt = %v, want it bumped past CreatedAt %v", got.UpdatedAt, got.CreatedAt)
}
}
func TestWritePlanRevisionRollsBackWhenCoalesceTargetMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-writeplan-rollback")
missingID := "planrev-does-not-exist"
err := repo.WritePlanRevision(ctx,
&models.TaskPlan{ID: "plan-rollback", TaskID: "task-writeplan-rollback", Title: "nope", Content: "nope"},
&models.TaskPlanRevision{TaskID: "task-writeplan-rollback", Title: "x", Content: "y"},
&missingID)
if err == nil {
t.Fatal("WritePlanRevision with an unknown coalesce id returned nil")
}
if !strings.Contains(err.Error(), "task plan revision not found") {
t.Errorf("error = %q, want a task-plan-revision-not-found message", err)
}
if got := countRows(t, repo,
`SELECT COUNT(1) FROM task_plans WHERE task_id = ?`, "task-writeplan-rollback"); got != 0 {
t.Errorf("HEAD rows after a failed merge = %d, want 0 (transaction must roll back)", got)
}
if got := countRows(t, repo,
`SELECT COUNT(1) FROM task_plan_revisions WHERE task_id = ?`, "task-writeplan-rollback"); got != 0 {
t.Errorf("revision rows after a failed merge = %d, want 0", got)
}
}
func TestTaskPlanAndRevisionsCascadeOnTaskDelete(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-cascade")
if err := repo.WritePlanRevision(ctx,
&models.TaskPlan{ID: "plan-cascade", TaskID: "task-plan-cascade", Title: "Plan", Content: "body"},
&models.TaskPlanRevision{TaskID: "task-plan-cascade", Title: "Plan", Content: "body", AuthorKind: "agent"},
nil); err != nil {
t.Fatalf("WritePlanRevision: %v", err)
}
if err := repo.DeleteTask(ctx, "task-plan-cascade"); err != nil {
t.Fatalf("DeleteTask: %v", err)
}
if got := countRows(t, repo, `SELECT COUNT(1) FROM task_plans WHERE task_id = ?`, "task-plan-cascade"); got != 0 {
t.Errorf("plan rows after task delete = %d, want 0 (FK cascade)", got)
}
if got := countRows(t, repo,
`SELECT COUNT(1) FROM task_plan_revisions WHERE task_id = ?`, "task-plan-cascade"); got != 0 {
t.Errorf("plan revision rows after task delete = %d, want 0 (FK cascade)", got)
}
}
func TestTaskPlanMethodsPropagateBackendErrors(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedTaskForDocs(t, repo, "task-plan-broken")
if err := repo.CreateTaskPlan(ctx, &models.TaskPlan{
ID: "plan-broken", TaskID: "task-plan-broken", Title: "Plan", Content: "body",
}); err != nil {
t.Fatalf("CreateTaskPlan: %v", err)
}
closeRepoDB(t, repo)
if err := repo.CreateTaskPlan(ctx, &models.TaskPlan{ID: "plan-after-close", TaskID: "task-plan-broken"}); err == nil {
t.Error("CreateTaskPlan on a closed database returned nil")
}
if _, err := repo.GetTaskPlan(ctx, "task-plan-broken"); err == nil {
t.Error("GetTaskPlan on a closed database returned nil")
}
if err := repo.UpdateTaskPlan(ctx, &models.TaskPlan{TaskID: "task-plan-broken"}); err == nil {
t.Error("UpdateTaskPlan on a closed database returned nil")
}
if _, err := repo.MarkTaskPlanImplementationStarted(ctx, "task-plan-broken", "s", "a"); err == nil {
t.Error("MarkTaskPlanImplementationStarted on a closed database returned nil")
}
if err := repo.DeleteTaskPlan(ctx, "task-plan-broken"); err == nil {
t.Error("DeleteTaskPlan on a closed database returned nil")
}
if err := repo.InsertTaskPlanRevision(ctx, &models.TaskPlanRevision{
TaskID: "task-plan-broken", RevisionNumber: 1,
}); err == nil {
t.Error("InsertTaskPlanRevision on a closed database returned nil")
}
if err := repo.UpdateTaskPlanRevision(ctx, &models.TaskPlanRevision{ID: "planrev-broken"}); err == nil {
t.Error("UpdateTaskPlanRevision on a closed database returned nil")
}
if _, err := repo.GetTaskPlanRevision(ctx, "planrev-broken"); err == nil {
t.Error("GetTaskPlanRevision on a closed database returned nil")
}
if _, err := repo.ListTaskPlanRevisions(ctx, "task-plan-broken", 0); err == nil {
t.Error("ListTaskPlanRevisions on a closed database returned nil")
}
if _, err := repo.NextTaskPlanRevisionNumber(ctx, "task-plan-broken"); err == nil {
t.Error("NextTaskPlanRevisionNumber on a closed database returned nil")
}
if err := repo.WritePlanRevision(ctx,
&models.TaskPlan{TaskID: "task-plan-broken"},
&models.TaskPlanRevision{TaskID: "task-plan-broken"},
nil); err == nil {
t.Error("WritePlanRevision on a closed database returned nil")
}
}