a6a26911b7
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
839 lines
30 KiB
Go
839 lines
30 KiB
Go
package sqlite
|
|
|
|
//revive:disable:file-length-limit // Document HEAD + revision persistence coverage is intentionally scenario-heavy.
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
func seedTaskForDocs(t *testing.T, repo *Repository, taskID string) {
|
|
t.Helper()
|
|
if err := repo.CreateTask(context.Background(), &models.Task{ID: taskID, Title: taskID}); err != nil {
|
|
t.Fatalf("CreateTask(%s): %v", taskID, err)
|
|
}
|
|
}
|
|
|
|
func fullDocument(taskID string) *models.TaskDocument {
|
|
return &models.TaskDocument{
|
|
ID: "doc-full",
|
|
TaskID: taskID,
|
|
Key: "spec",
|
|
Type: "spec",
|
|
Title: "Feature specification",
|
|
Content: "# Spec\n\nEverything.",
|
|
AuthorKind: "user",
|
|
AuthorName: "jcfs",
|
|
Filename: "spec.md",
|
|
MimeType: "text/markdown",
|
|
SizeBytes: 4096,
|
|
DiskPath: "/var/kandev/docs/spec.md",
|
|
}
|
|
}
|
|
|
|
func assertDocumentEqual(t *testing.T, got, want *models.TaskDocument) {
|
|
t.Helper()
|
|
if got == nil {
|
|
t.Fatal("document 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.Key != want.Key {
|
|
t.Errorf("Key = %q, want %q", got.Key, want.Key)
|
|
}
|
|
if got.Type != want.Type {
|
|
t.Errorf("Type = %q, want %q", got.Type, want.Type)
|
|
}
|
|
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)
|
|
}
|
|
if got.Filename != want.Filename {
|
|
t.Errorf("Filename = %q, want %q", got.Filename, want.Filename)
|
|
}
|
|
if got.MimeType != want.MimeType {
|
|
t.Errorf("MimeType = %q, want %q", got.MimeType, want.MimeType)
|
|
}
|
|
if got.SizeBytes != want.SizeBytes {
|
|
t.Errorf("SizeBytes = %d, want %d", got.SizeBytes, want.SizeBytes)
|
|
}
|
|
if got.DiskPath != want.DiskPath {
|
|
t.Errorf("DiskPath = %q, want %q", got.DiskPath, want.DiskPath)
|
|
}
|
|
assertTimeEqual(t, "CreatedAt", got.CreatedAt, want.CreatedAt)
|
|
assertTimeEqual(t, "UpdatedAt", got.UpdatedAt, want.UpdatedAt)
|
|
}
|
|
|
|
func assertDocRevisionEqual(t *testing.T, got, want *models.TaskDocumentRevision) {
|
|
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.DocumentKey != want.DocumentKey {
|
|
t.Errorf("DocumentKey = %q, want %q", got.DocumentKey, want.DocumentKey)
|
|
}
|
|
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)
|
|
}
|
|
switch {
|
|
case want.RevertOfRevisionID == nil && got.RevertOfRevisionID != nil:
|
|
t.Errorf("RevertOfRevisionID = %q, want nil", *got.RevertOfRevisionID)
|
|
case want.RevertOfRevisionID != nil && got.RevertOfRevisionID == nil:
|
|
t.Errorf("RevertOfRevisionID = nil, want %q", *want.RevertOfRevisionID)
|
|
case want.RevertOfRevisionID != nil && *got.RevertOfRevisionID != *want.RevertOfRevisionID:
|
|
t.Errorf("RevertOfRevisionID = %q, want %q", *got.RevertOfRevisionID, *want.RevertOfRevisionID)
|
|
}
|
|
assertTimeEqual(t, "CreatedAt", got.CreatedAt, want.CreatedAt)
|
|
assertTimeEqual(t, "UpdatedAt", got.UpdatedAt, want.UpdatedAt)
|
|
}
|
|
|
|
func TestCreateDocumentRoundTripsEveryField(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-full")
|
|
|
|
want := fullDocument("task-doc-full")
|
|
before := time.Now().UTC().Add(-time.Second)
|
|
if err := repo.CreateDocument(ctx, want); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
if want.CreatedAt.Before(before) || !want.CreatedAt.Equal(want.UpdatedAt) {
|
|
t.Fatalf("CreateDocument stamped CreatedAt=%v UpdatedAt=%v, want equal times after %v",
|
|
want.CreatedAt, want.UpdatedAt, before)
|
|
}
|
|
|
|
got, err := repo.GetDocument(ctx, "task-doc-full", "spec")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
assertDocumentEqual(t, got, want)
|
|
|
|
listed, err := repo.ListDocuments(ctx, "task-doc-full")
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments: %v", err)
|
|
}
|
|
if len(listed) != 1 {
|
|
t.Fatalf("ListDocuments returned %d rows, want 1", len(listed))
|
|
}
|
|
assertDocumentEqual(t, listed[0], want)
|
|
}
|
|
|
|
func TestCreateDocumentGeneratesIDWhenEmpty(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-id")
|
|
|
|
doc := &models.TaskDocument{TaskID: "task-doc-id", Key: "plan", Type: "plan", Title: "Plan"}
|
|
if err := repo.CreateDocument(ctx, doc); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
if doc.ID == "" {
|
|
t.Fatal("CreateDocument left ID empty; a UUID should have been generated")
|
|
}
|
|
got, err := repo.GetDocument(ctx, "task-doc-id", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
if got.ID != doc.ID {
|
|
t.Errorf("persisted ID = %q, want the generated %q", got.ID, doc.ID)
|
|
}
|
|
}
|
|
|
|
func TestCreateDocumentRejectsDuplicateTaskKey(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-dupe")
|
|
|
|
first := &models.TaskDocument{ID: "doc-dupe-1", TaskID: "task-doc-dupe", Key: "plan", Type: "plan", Title: "One"}
|
|
if err := repo.CreateDocument(ctx, first); err != nil {
|
|
t.Fatalf("CreateDocument(first): %v", err)
|
|
}
|
|
second := &models.TaskDocument{ID: "doc-dupe-2", TaskID: "task-doc-dupe", Key: "plan", Type: "plan", Title: "Two"}
|
|
if err := repo.CreateDocument(ctx, second); err == nil {
|
|
t.Fatal("CreateDocument accepted a duplicate (task_id, key); the UNIQUE constraint should reject it")
|
|
}
|
|
got, err := repo.GetDocument(ctx, "task-doc-dupe", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
if got.Title != "One" {
|
|
t.Errorf("Title = %q, want the original %q to survive the rejected insert", got.Title, "One")
|
|
}
|
|
}
|
|
|
|
func TestGetDocumentReturnsNilNilWhenMissing(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
|
|
got, err := repo.GetDocument(ctx, "task-doc-missing", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument returned error %v, want the nil,nil not-found contract", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("GetDocument = %+v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateDocumentWritesEveryMutableFieldAndReportsMissing(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-update")
|
|
|
|
doc := &models.TaskDocument{
|
|
ID: "doc-update", TaskID: "task-doc-update", Key: "notes", Type: "notes",
|
|
Title: "Before", Content: "old", AuthorKind: "agent", AuthorName: "claude",
|
|
Filename: "old.md", MimeType: "text/plain", SizeBytes: 1, DiskPath: "/old",
|
|
}
|
|
if err := repo.CreateDocument(ctx, doc); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
createdAt := doc.CreatedAt
|
|
|
|
doc.Type = "spec"
|
|
doc.Title = "After"
|
|
doc.Content = "new content"
|
|
doc.AuthorKind = "user"
|
|
doc.AuthorName = "jcfs"
|
|
doc.Filename = "new.md"
|
|
doc.MimeType = "text/markdown"
|
|
doc.SizeBytes = 999
|
|
doc.DiskPath = "/new"
|
|
if err := repo.UpdateDocument(ctx, doc); err != nil {
|
|
t.Fatalf("UpdateDocument: %v", err)
|
|
}
|
|
|
|
got, err := repo.GetDocument(ctx, "task-doc-update", "notes")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
assertDocumentEqual(t, got, doc)
|
|
if !got.CreatedAt.Equal(createdAt) {
|
|
t.Errorf("CreatedAt = %v, want it preserved at %v across the update", got.CreatedAt, createdAt)
|
|
}
|
|
if !got.UpdatedAt.After(createdAt) {
|
|
t.Errorf("UpdatedAt = %v, want it bumped past CreatedAt %v", got.UpdatedAt, createdAt)
|
|
}
|
|
|
|
missing := &models.TaskDocument{TaskID: "task-doc-update", Key: "nope"}
|
|
err = repo.UpdateDocument(ctx, missing)
|
|
if err == nil {
|
|
t.Fatal("UpdateDocument on a missing row returned nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "document not found") {
|
|
t.Errorf("UpdateDocument error = %q, want a document-not-found message", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteDocumentRemovesHeadAndReportsMissing(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-delete")
|
|
|
|
for _, key := range []string{"plan", "spec"} {
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-del-" + key, TaskID: "task-doc-delete", Key: key, Type: key, Title: key,
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument(%s): %v", key, err)
|
|
}
|
|
}
|
|
|
|
if err := repo.DeleteDocument(ctx, "task-doc-delete", "plan"); err != nil {
|
|
t.Fatalf("DeleteDocument: %v", err)
|
|
}
|
|
if got := countRows(t, repo, `SELECT COUNT(1) FROM task_documents WHERE task_id = ?`, "task-doc-delete"); got != 1 {
|
|
t.Errorf("rows after delete = %d, want 1", got)
|
|
}
|
|
gone, err := repo.GetDocument(ctx, "task-doc-delete", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
if gone != nil {
|
|
t.Errorf("deleted document still readable: %+v", gone)
|
|
}
|
|
survivor, err := repo.GetDocument(ctx, "task-doc-delete", "spec")
|
|
if err != nil || survivor == nil {
|
|
t.Fatalf("GetDocument(spec) = %+v, %v; want the sibling key untouched", survivor, err)
|
|
}
|
|
|
|
err = repo.DeleteDocument(ctx, "task-doc-delete", "plan")
|
|
if err == nil {
|
|
t.Fatal("second DeleteDocument returned nil; a missing row must be reported")
|
|
}
|
|
if !strings.Contains(err.Error(), "document not found") {
|
|
t.Errorf("DeleteDocument error = %q, want a document-not-found message", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteDocumentLeavesRevisionsBehind pins the current behavior: the
|
|
// revisions table's only foreign key points at tasks(id), not at the document
|
|
// HEAD row, so removing a HEAD does not cascade to its history.
|
|
func TestDeleteDocumentLeavesRevisionsBehind(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-orphan")
|
|
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-orphan", TaskID: "task-doc-orphan", Key: "plan", Type: "plan", Title: "Plan",
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
if err := repo.InsertDocumentRevision(ctx, &models.TaskDocumentRevision{
|
|
ID: "rev-orphan", TaskID: "task-doc-orphan", DocumentKey: "plan", RevisionNumber: 1,
|
|
Title: "Plan", Content: "v1", AuthorKind: "agent",
|
|
}); err != nil {
|
|
t.Fatalf("InsertDocumentRevision: %v", err)
|
|
}
|
|
|
|
if err := repo.DeleteDocument(ctx, "task-doc-orphan", "plan"); err != nil {
|
|
t.Fatalf("DeleteDocument: %v", err)
|
|
}
|
|
if got := countRows(t, repo,
|
|
`SELECT COUNT(1) FROM task_document_revisions WHERE task_id = ?`, "task-doc-orphan"); got != 1 {
|
|
t.Errorf("revision rows after HEAD delete = %d, want 1 (no cascade from task_documents)", got)
|
|
}
|
|
}
|
|
|
|
func TestDocumentsAndRevisionsCascadeOnTaskDelete(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-cascade")
|
|
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-cascade", TaskID: "task-doc-cascade", Key: "plan", Type: "plan", Title: "Plan",
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
if err := repo.InsertDocumentRevision(ctx, &models.TaskDocumentRevision{
|
|
ID: "rev-cascade", TaskID: "task-doc-cascade", DocumentKey: "plan", RevisionNumber: 1,
|
|
Title: "Plan", Content: "v1", AuthorKind: "agent",
|
|
}); err != nil {
|
|
t.Fatalf("InsertDocumentRevision: %v", err)
|
|
}
|
|
|
|
if err := repo.DeleteTask(ctx, "task-doc-cascade"); err != nil {
|
|
t.Fatalf("DeleteTask: %v", err)
|
|
}
|
|
if got := countRows(t, repo, `SELECT COUNT(1) FROM task_documents WHERE task_id = ?`, "task-doc-cascade"); got != 0 {
|
|
t.Errorf("document rows after task delete = %d, want 0 (FK cascade)", got)
|
|
}
|
|
if got := countRows(t, repo,
|
|
`SELECT COUNT(1) FROM task_document_revisions WHERE task_id = ?`, "task-doc-cascade"); got != 0 {
|
|
t.Errorf("revision rows after task delete = %d, want 0 (FK cascade)", got)
|
|
}
|
|
}
|
|
|
|
func TestListDocumentsOrdersByKeyAndScopesToTask(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-list")
|
|
seedTaskForDocs(t, repo, "task-doc-list-other")
|
|
|
|
for _, key := range []string{"spec", "alpha", "notes"} {
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-list-" + key, TaskID: "task-doc-list", Key: key, Type: key, Title: key,
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument(%s): %v", key, err)
|
|
}
|
|
}
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-list-foreign", TaskID: "task-doc-list-other", Key: "aaa", Type: "plan", Title: "foreign",
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument(foreign): %v", err)
|
|
}
|
|
|
|
got, err := repo.ListDocuments(ctx, "task-doc-list")
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments: %v", err)
|
|
}
|
|
want := []string{"alpha", "notes", "spec"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("ListDocuments returned %d rows, want %d", len(got), len(want))
|
|
}
|
|
for i := range want {
|
|
if got[i].Key != want[i] {
|
|
t.Fatalf("ListDocuments keys = %q at index %d, want %q (ORDER BY key)", got[i].Key, i, want[i])
|
|
}
|
|
}
|
|
|
|
empty, err := repo.ListDocuments(ctx, "task-doc-list-nothing")
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments(unknown task): %v", err)
|
|
}
|
|
if len(empty) != 0 {
|
|
t.Errorf("ListDocuments(unknown task) = %+v, want empty", empty)
|
|
}
|
|
}
|
|
|
|
func TestInsertDocumentRevisionRoundTripsEveryField(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-rev-full")
|
|
|
|
revertOf := "rev-previous"
|
|
want := &models.TaskDocumentRevision{
|
|
ID: "rev-full", TaskID: "task-rev-full", DocumentKey: "spec", RevisionNumber: 7,
|
|
Title: "Spec v7", Content: "seven", AuthorKind: "user", AuthorName: "jcfs",
|
|
RevertOfRevisionID: &revertOf,
|
|
CreatedAt: time.Date(2026, 3, 3, 3, 3, 3, 456000000, time.UTC),
|
|
}
|
|
if err := repo.InsertDocumentRevision(ctx, want); err != nil {
|
|
t.Fatalf("InsertDocumentRevision: %v", err)
|
|
}
|
|
if want.UpdatedAt.IsZero() {
|
|
t.Fatal("InsertDocumentRevision left UpdatedAt zero")
|
|
}
|
|
|
|
got, err := repo.GetDocumentRevision(ctx, "rev-full")
|
|
if err != nil {
|
|
t.Fatalf("GetDocumentRevision: %v", err)
|
|
}
|
|
assertDocRevisionEqual(t, got, want)
|
|
|
|
latest, err := repo.GetLatestDocumentRevision(ctx, "task-rev-full", "spec")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestDocumentRevision: %v", err)
|
|
}
|
|
assertDocRevisionEqual(t, latest, want)
|
|
|
|
listed, err := repo.ListDocumentRevisions(ctx, "task-rev-full", "spec", 0)
|
|
if err != nil {
|
|
t.Fatalf("ListDocumentRevisions: %v", err)
|
|
}
|
|
if len(listed) != 1 {
|
|
t.Fatalf("ListDocumentRevisions returned %d rows, want 1", len(listed))
|
|
}
|
|
assertDocRevisionEqual(t, listed[0], want)
|
|
}
|
|
|
|
func TestInsertDocumentRevisionPreservesSuppliedCreatedAtAndDefaultsID(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-rev-defaults")
|
|
|
|
rev := &models.TaskDocumentRevision{
|
|
TaskID: "task-rev-defaults", DocumentKey: "plan", RevisionNumber: 1,
|
|
Title: "Plan", Content: "v1", AuthorKind: "agent",
|
|
}
|
|
before := time.Now().UTC().Add(-time.Second)
|
|
if err := repo.InsertDocumentRevision(ctx, rev); err != nil {
|
|
t.Fatalf("InsertDocumentRevision: %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.GetDocumentRevision(ctx, rev.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDocumentRevision: %v", err)
|
|
}
|
|
if got.RevertOfRevisionID != nil {
|
|
t.Errorf("RevertOfRevisionID = %q, want nil for a NULL column", *got.RevertOfRevisionID)
|
|
}
|
|
}
|
|
|
|
func TestGetDocumentRevisionReturnsNilNilWhenMissing(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
|
|
got, err := repo.GetDocumentRevision(ctx, "rev-missing")
|
|
if err != nil {
|
|
t.Fatalf("GetDocumentRevision returned error %v, want the nil,nil not-found contract", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("GetDocumentRevision = %+v, want nil", got)
|
|
}
|
|
latest, err := repo.GetLatestDocumentRevision(ctx, "task-missing", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestDocumentRevision returned error %v, want nil,nil", err)
|
|
}
|
|
if latest != nil {
|
|
t.Errorf("GetLatestDocumentRevision = %+v, want nil", latest)
|
|
}
|
|
}
|
|
|
|
func TestListDocumentRevisionsOrdersDescendingScopedByKeyAndHonoursLimit(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-rev-list")
|
|
|
|
for _, key := range []string{"plan", "spec"} {
|
|
for number := 1; number <= 3; number++ {
|
|
if err := repo.InsertDocumentRevision(ctx, &models.TaskDocumentRevision{
|
|
ID: "rev-" + key + "-" + string(rune('0'+number)),
|
|
TaskID: "task-rev-list", DocumentKey: key, RevisionNumber: number,
|
|
Title: key,
|
|
Content: key, AuthorKind: "agent",
|
|
}); err != nil {
|
|
t.Fatalf("InsertDocumentRevision(%s %d): %v", key, number, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
all, err := repo.ListDocumentRevisions(ctx, "task-rev-list", "plan", 0)
|
|
if err != nil {
|
|
t.Fatalf("ListDocumentRevisions(limit=0): %v", err)
|
|
}
|
|
if len(all) != 3 {
|
|
t.Fatalf("limit=0 returned %d rows, want 3 (only the plan key)", 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)
|
|
}
|
|
if all[i].DocumentKey != "plan" {
|
|
t.Fatalf("document key = %q at index %d, want plan", all[i].DocumentKey, i)
|
|
}
|
|
}
|
|
|
|
limited, err := repo.ListDocumentRevisions(ctx, "task-rev-list", "plan", 2)
|
|
if err != nil {
|
|
t.Fatalf("ListDocumentRevisions(limit=2): %v", err)
|
|
}
|
|
if len(limited) != 2 || limited[0].RevisionNumber != 3 || limited[1].RevisionNumber != 2 {
|
|
t.Errorf("limit=2 returned %+v, want revisions 3 then 2", limited)
|
|
}
|
|
|
|
latest, err := repo.GetLatestDocumentRevision(ctx, "task-rev-list", "spec")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestDocumentRevision: %v", err)
|
|
}
|
|
if latest.RevisionNumber != 3 || latest.DocumentKey != "spec" {
|
|
t.Errorf("latest spec revision = %+v, want revision 3 of spec", latest)
|
|
}
|
|
}
|
|
|
|
func TestNextDocumentRevisionNumber(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-rev-next")
|
|
|
|
next, err := repo.NextDocumentRevisionNumber(ctx, "task-rev-next", "plan")
|
|
if err != nil {
|
|
t.Fatalf("NextDocumentRevisionNumber: %v", err)
|
|
}
|
|
if next != 1 {
|
|
t.Errorf("NextDocumentRevisionNumber with no history = %d, want 1", next)
|
|
}
|
|
|
|
if err := repo.InsertDocumentRevision(ctx, &models.TaskDocumentRevision{
|
|
ID: "rev-next-5", TaskID: "task-rev-next", DocumentKey: "plan", RevisionNumber: 5,
|
|
Title: "Plan", Content: "v5", AuthorKind: "agent",
|
|
}); err != nil {
|
|
t.Fatalf("InsertDocumentRevision: %v", err)
|
|
}
|
|
// A different key must not influence the plan key's numbering.
|
|
if err := repo.InsertDocumentRevision(ctx, &models.TaskDocumentRevision{
|
|
ID: "rev-next-spec-9", TaskID: "task-rev-next", DocumentKey: "spec", RevisionNumber: 9,
|
|
Title: "Spec", Content: "v9", AuthorKind: "agent",
|
|
}); err != nil {
|
|
t.Fatalf("InsertDocumentRevision(spec): %v", err)
|
|
}
|
|
|
|
next, err = repo.NextDocumentRevisionNumber(ctx, "task-rev-next", "plan")
|
|
if err != nil {
|
|
t.Fatalf("NextDocumentRevisionNumber: %v", err)
|
|
}
|
|
if next != 6 {
|
|
t.Errorf("NextDocumentRevisionNumber = %d, want 6 (MAX(5)+1 for the plan key only)", next)
|
|
}
|
|
}
|
|
|
|
func TestWriteDocumentRevisionCreatesHeadAndFirstRevision(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-write-doc")
|
|
|
|
head := &models.TaskDocument{
|
|
TaskID: "task-write-doc", Key: "plan", Type: "plan", Title: "Plan v1",
|
|
Content: "first", AuthorKind: "agent", AuthorName: "claude",
|
|
Filename: "plan.md", MimeType: "text/markdown", SizeBytes: 5, DiskPath: "/plan.md",
|
|
}
|
|
rev := &models.TaskDocumentRevision{
|
|
TaskID: "task-write-doc", DocumentKey: "plan",
|
|
Title: "Plan v1", Content: "first", AuthorKind: "agent", AuthorName: "claude",
|
|
}
|
|
if err := repo.WriteDocumentRevision(ctx, head, rev, nil); err != nil {
|
|
t.Fatalf("WriteDocumentRevision: %v", err)
|
|
}
|
|
if head.ID == "" {
|
|
t.Fatal("head ID left empty; a UUID should have been generated")
|
|
}
|
|
if rev.RevisionNumber != 1 {
|
|
t.Errorf("RevisionNumber = %d, want 1 for the first revision", rev.RevisionNumber)
|
|
}
|
|
|
|
gotHead, err := repo.GetDocument(ctx, "task-write-doc", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
assertDocumentEqual(t, gotHead, head)
|
|
|
|
gotRev, err := repo.GetLatestDocumentRevision(ctx, "task-write-doc", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetLatestDocumentRevision: %v", err)
|
|
}
|
|
assertDocRevisionEqual(t, gotRev, rev)
|
|
}
|
|
|
|
func TestWriteDocumentRevisionUpdatesHeadInPlaceAndAppendsRevisions(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-write-doc-append")
|
|
|
|
head := &models.TaskDocument{
|
|
ID: "head-append", TaskID: "task-write-doc-append", Key: "plan", Type: "plan",
|
|
Title: "v1", Content: "one", AuthorKind: "agent",
|
|
}
|
|
if err := repo.WriteDocumentRevision(ctx, head, &models.TaskDocumentRevision{
|
|
TaskID: "task-write-doc-append", DocumentKey: "plan", Title: "v1", Content: "one", AuthorKind: "agent",
|
|
}, nil); err != nil {
|
|
t.Fatalf("WriteDocumentRevision(first): %v", err)
|
|
}
|
|
createdAt := head.CreatedAt
|
|
|
|
head.Title = "v2"
|
|
head.Content = "two"
|
|
head.SizeBytes = 3
|
|
second := &models.TaskDocumentRevision{
|
|
TaskID: "task-write-doc-append", DocumentKey: "plan", Title: "v2", Content: "two", AuthorKind: "user",
|
|
}
|
|
if err := repo.WriteDocumentRevision(ctx, head, second, nil); err != nil {
|
|
t.Fatalf("WriteDocumentRevision(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_documents WHERE task_id = ?`, "task-write-doc-append"); got != 1 {
|
|
t.Errorf("HEAD rows = %d, want 1 (the upsert must not insert a second row)", got)
|
|
}
|
|
gotHead, err := repo.GetDocument(ctx, "task-write-doc-append", "plan")
|
|
if err != nil {
|
|
t.Fatalf("GetDocument: %v", err)
|
|
}
|
|
if gotHead.ID != "head-append" {
|
|
t.Errorf("HEAD id = %q, want the original head-append", gotHead.ID)
|
|
}
|
|
if gotHead.Title != "v2" || gotHead.Content != "two" || gotHead.SizeBytes != 3 {
|
|
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.ListDocumentRevisions(ctx, "task-write-doc-append", "plan", 0)
|
|
if err != nil {
|
|
t.Fatalf("ListDocumentRevisions: %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].AuthorKind != "user" {
|
|
t.Errorf("newest revision = %+v, want revision 2 authored by user", 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 TestWriteDocumentRevisionCoalescesIntoExistingRevision(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-write-doc-merge")
|
|
|
|
head := &models.TaskDocument{
|
|
ID: "head-merge", TaskID: "task-write-doc-merge", Key: "plan", Type: "plan",
|
|
Title: "v1", Content: "one", AuthorKind: "agent",
|
|
}
|
|
first := &models.TaskDocumentRevision{
|
|
ID: "rev-merge", TaskID: "task-write-doc-merge", DocumentKey: "plan",
|
|
Title: "v1", Content: "one", AuthorKind: "agent", AuthorName: "claude",
|
|
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
}
|
|
if err := repo.WriteDocumentRevision(ctx, head, first, nil); err != nil {
|
|
t.Fatalf("WriteDocumentRevision(first): %v", err)
|
|
}
|
|
|
|
head.Title = "v1 edited"
|
|
head.Content = "one edited"
|
|
merged := &models.TaskDocumentRevision{
|
|
TaskID: "task-write-doc-merge", DocumentKey: "plan",
|
|
Title: "v1 edited", Content: "one edited", AuthorKind: "ignored", AuthorName: "ignored",
|
|
}
|
|
coalesceID := "rev-merge"
|
|
if err := repo.WriteDocumentRevision(ctx, head, merged, &coalesceID); err != nil {
|
|
t.Fatalf("WriteDocumentRevision(merge): %v", err)
|
|
}
|
|
if merged.ID != "rev-merge" {
|
|
t.Errorf("merged revision ID = %q, want rev-merge", merged.ID)
|
|
}
|
|
|
|
history, err := repo.ListDocumentRevisions(ctx, "task-write-doc-merge", "plan", 0)
|
|
if err != nil {
|
|
t.Fatalf("ListDocumentRevisions: %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 across the merge", 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 the original %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 TestWriteDocumentRevisionRollsBackWhenCoalesceTargetMissing(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-write-doc-rollback")
|
|
|
|
head := &models.TaskDocument{
|
|
ID: "head-rollback", TaskID: "task-write-doc-rollback", Key: "plan", Type: "plan",
|
|
Title: "should not persist", Content: "nope", AuthorKind: "agent",
|
|
}
|
|
missingID := "rev-does-not-exist"
|
|
err := repo.WriteDocumentRevision(ctx, head, &models.TaskDocumentRevision{
|
|
TaskID: "task-write-doc-rollback", DocumentKey: "plan", Title: "x", Content: "y",
|
|
}, &missingID)
|
|
if err == nil {
|
|
t.Fatal("WriteDocumentRevision with an unknown coalesce id returned nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "document revision not found") {
|
|
t.Errorf("error = %q, want a document-revision-not-found message", err)
|
|
}
|
|
|
|
// The HEAD upsert happened inside the same transaction and must roll back.
|
|
if got := countRows(t, repo,
|
|
`SELECT COUNT(1) FROM task_documents WHERE task_id = ?`, "task-write-doc-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_document_revisions WHERE task_id = ?`, "task-write-doc-rollback"); got != 0 {
|
|
t.Errorf("revision rows after a failed merge = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteDocumentRevisionTreatsEmptyCoalesceIDAsInsert(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-write-doc-empty")
|
|
|
|
head := &models.TaskDocument{
|
|
ID: "head-empty", TaskID: "task-write-doc-empty", Key: "plan", Type: "plan",
|
|
Title: "v1", Content: "one", AuthorKind: "agent",
|
|
}
|
|
empty := ""
|
|
rev := &models.TaskDocumentRevision{
|
|
TaskID: "task-write-doc-empty", DocumentKey: "plan", Title: "v1", Content: "one", AuthorKind: "agent",
|
|
}
|
|
if err := repo.WriteDocumentRevision(ctx, head, rev, &empty); err != nil {
|
|
t.Fatalf("WriteDocumentRevision: %v", err)
|
|
}
|
|
if rev.RevisionNumber != 1 {
|
|
t.Errorf("RevisionNumber = %d, want 1 — an empty coalesce id means append", rev.RevisionNumber)
|
|
}
|
|
if got := countRows(t, repo,
|
|
`SELECT COUNT(1) FROM task_document_revisions WHERE task_id = ?`, "task-write-doc-empty"); got != 1 {
|
|
t.Errorf("revision rows = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestDocumentMethodsPropagateBackendErrors(t *testing.T) {
|
|
repo := newRepoForEntityTests(t)
|
|
ctx := context.Background()
|
|
seedTaskForDocs(t, repo, "task-doc-broken")
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-broken", TaskID: "task-doc-broken", Key: "plan", Type: "plan", Title: "Plan",
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
closeRepoDB(t, repo)
|
|
|
|
if err := repo.CreateDocument(ctx, &models.TaskDocument{
|
|
ID: "doc-after-close", TaskID: "task-doc-broken", Key: "spec",
|
|
}); err == nil {
|
|
t.Error("CreateDocument on a closed database returned nil")
|
|
}
|
|
if _, err := repo.GetDocument(ctx, "task-doc-broken", "plan"); err == nil {
|
|
t.Error("GetDocument on a closed database returned nil")
|
|
}
|
|
if err := repo.UpdateDocument(ctx, &models.TaskDocument{TaskID: "task-doc-broken", Key: "plan"}); err == nil {
|
|
t.Error("UpdateDocument on a closed database returned nil")
|
|
}
|
|
if err := repo.DeleteDocument(ctx, "task-doc-broken", "plan"); err == nil {
|
|
t.Error("DeleteDocument on a closed database returned nil")
|
|
}
|
|
if _, err := repo.ListDocuments(ctx, "task-doc-broken"); err == nil {
|
|
t.Error("ListDocuments on a closed database returned nil")
|
|
}
|
|
if err := repo.InsertDocumentRevision(ctx, &models.TaskDocumentRevision{
|
|
TaskID: "task-doc-broken", DocumentKey: "plan", RevisionNumber: 1,
|
|
}); err == nil {
|
|
t.Error("InsertDocumentRevision on a closed database returned nil")
|
|
}
|
|
if _, err := repo.GetDocumentRevision(ctx, "rev-broken"); err == nil {
|
|
t.Error("GetDocumentRevision on a closed database returned nil")
|
|
}
|
|
if _, err := repo.ListDocumentRevisions(ctx, "task-doc-broken", "plan", 0); err == nil {
|
|
t.Error("ListDocumentRevisions on a closed database returned nil")
|
|
}
|
|
if _, err := repo.NextDocumentRevisionNumber(ctx, "task-doc-broken", "plan"); err == nil {
|
|
t.Error("NextDocumentRevisionNumber on a closed database returned nil")
|
|
}
|
|
if err := repo.WriteDocumentRevision(ctx,
|
|
&models.TaskDocument{TaskID: "task-doc-broken", Key: "plan"},
|
|
&models.TaskDocumentRevision{TaskID: "task-doc-broken", DocumentKey: "plan"},
|
|
nil); err == nil {
|
|
t.Error("WriteDocumentRevision on a closed database returned nil")
|
|
}
|
|
}
|