Files
pansy/internal/service/revisions_test.go
T
steveandClaude Fable 5 bc14bbed0d
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 10m2s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m2s
Agent: what a day of live use asked for
Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:

- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
  sent by the UI like plantedAt) into the system prompt and every dated tool
  default. Left to guess, the model dated journal entries a year back; left to
  the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
  maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
  group out on demand and remove_plantings acts on one plant in a bed ("take
  the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
  MovePlanting, which is why the store's UPDATE now writes object_id),
  update_plant, read_history, copy_garden (the "<garden> — <year>" plan
  convention). fill_region takes an explicit local rectangle and a seedLotId;
  place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
  a change no tool made, says it cannot undo and points at the Undo button,
  asks before clearing beds on an ambiguous sentence, and stops narrating its
  own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
  garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
  put; every tool has a step label; wide tables stay inside the bubble.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 00:14:41 -04:00

943 lines
35 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// history returns a garden's change sets newest-first, failing the test on error.
func history(t *testing.T, s *Service, actor, gardenID int64) []domain.ChangeSet {
t.Helper()
sets, _, err := s.GardenHistory(context.Background(), actor, gardenID, 0, 0)
if err != nil {
t.Fatalf("GardenHistory: %v", err)
}
return sets
}
// revisionsOf loads a change set's revisions.
func revisionsOf(t *testing.T, s *Service, id int64) []domain.Revision {
t.Helper()
cs, err := s.store.GetChangeSet(context.Background(), id)
if err != nil {
t.Fatalf("GetChangeSet: %v", err)
}
return cs.Revisions
}
// TestAutoScopeRecordsEveryMutation is the acceptance criterion that keeps this
// feature shallow: a plain REST-shaped mutation, with nobody opening a change
// set, still lands in history.
func TestAutoScopeRecordsEveryMutation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
bed := seedBed(t, s, owner, g.ID)
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
sets := history(t, s, owner, g.ID)
if len(sets) != 2 {
t.Fatalf("got %d change sets, want 2 (create + update)", len(sets))
}
// Newest first.
if sets[0].Summary != "Edited North Bed" {
t.Errorf("newest summary = %q", sets[0].Summary)
}
if sets[0].Source != domain.SourceUI {
t.Errorf("source = %q, want ui", sets[0].Source)
}
if sets[0].ActorID != owner || sets[0].ActorName == "" {
t.Errorf("actor not resolved: %+v", sets[0])
}
if len(sets[0].Counts) != 1 || sets[0].Counts[0].Op != domain.OpUpdate || sets[0].Counts[0].N != 1 {
t.Errorf("counts = %+v", sets[0].Counts)
}
}
// TestFillRegionIsOneChangeSet: N plops, one undo. The whole reason the unit of
// undo is the operation and not the row.
func TestFillRegionIsOneChangeSet(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil)
if err != nil {
t.Fatalf("FillNamedRegion: %v", err)
}
if len(created) < 2 {
t.Fatalf("expected a multi-plop fill, got %d", len(created))
}
sets := history(t, s, owner, g.ID)
fill := sets[0]
revs := revisionsOf(t, s, fill.ID)
if len(revs) != len(created) {
t.Fatalf("fill produced %d revisions for %d plops", len(revs), len(created))
}
for _, r := range revs {
if r.Op != domain.OpCreate || r.EntityType != domain.EntityPlanting {
t.Errorf("unexpected revision %+v", r)
}
if r.Before != nil || r.After == nil {
t.Errorf("a create should snapshot only the after state: %+v", r)
}
}
// Reverting removes all N and leaves the bed as it was.
_, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
if err != nil {
t.Fatalf("RevertChangeSet: %v", err)
}
if len(conflicts) != 0 {
t.Fatalf("unexpected conflicts: %+v", conflicts)
}
active, err := s.store.ListActivePlantingsForObject(ctx, bed.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(active) != 0 {
t.Errorf("%d plops survived the revert", len(active))
}
}
// TestRevertRestoresObjectGeometry covers the everyday case: move a bed, undo it.
func TestRevertRestoresObjectGeometry(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
moved, err := s.UpdateObject(ctx, owner, bed.ID,
ObjectPatch{XCM: f64Ptr(300), YCM: f64Ptr(400)}, bed.Version)
if err != nil {
t.Fatalf("UpdateObject: %v", err)
}
sets := history(t, s, owner, g.ID)
if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
}
back, err := s.store.GetObject(ctx, bed.ID)
if err != nil {
t.Fatalf("GetObject: %v", err)
}
if back.XCM != bed.XCM || back.YCM != bed.YCM {
t.Errorf("position = (%v,%v), want the original (%v,%v)", back.XCM, back.YCM, bed.XCM, bed.YCM)
}
// The revert is a WRITE, not a rewind: the row's version moves forward.
if back.Version <= moved.Version {
t.Errorf("version = %d, want > %d (revert is an append-only change)", back.Version, moved.Version)
}
}
// TestRevertOfRevertRestoresTheChange — undo is undoable, because a revert is
// itself a change set rather than a rewrite of history.
func TestRevertOfRevertRestoresTheChange(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
move := history(t, s, owner, g.ID)[0]
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceUI)
if err != nil {
t.Fatalf("revert: %v", err)
}
if undo.RevertsID == nil || *undo.RevertsID != move.ID {
t.Fatalf("revert doesn't point at its target: %+v", undo)
}
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != bed.XCM {
t.Fatalf("undo didn't restore x: %v", o.XCM)
}
redo, conflicts, err := s.RevertChangeSet(ctx, owner, undo.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert of revert: err=%v conflicts=%+v", err, conflicts)
}
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != 300 {
t.Errorf("redo didn't reapply the move: x = %v", o.XCM)
}
if redo.Summary == "" {
t.Error("a revert should carry a summary")
}
// The original is marked as reverted, and every step is still in the list.
sets := history(t, s, owner, g.ID)
if len(sets) != 4 { // create bed, move, undo, redo
t.Fatalf("got %d change sets, want 4", len(sets))
}
var moveEntry *domain.ChangeSet
for i := range sets {
if sets[i].ID == move.ID {
moveEntry = &sets[i]
}
}
if moveEntry == nil || moveEntry.RevertedByID == nil || *moveEntry.RevertedByID != undo.ID {
t.Errorf("the move isn't marked as reverted: %+v", moveEntry)
}
}
// TestRevertConflictsOnLaterEdit is the guard that stops undo from silently
// discarding someone's newer work — and the promise that the REST of the change
// set still reverts.
func TestRevertConflictsOnLaterEdit(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
bedA := seedBed(t, s, owner, g.ID)
bedB, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{
Kind: domain.KindBed, Name: "B", XCM: 200, YCM: 200, WidthCM: 100, HeightCM: 100,
})
if err != nil {
t.Fatalf("CreateObject: %v", err)
}
// One change set touching both beds.
_, err = s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Rearranged"},
func(ctx context.Context) error {
if _, err := s.UpdateObject(ctx, owner, bedA.ID, ObjectPatch{XCM: f64Ptr(600)}, bedA.Version); err != nil {
return err
}
_, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{XCM: f64Ptr(300)}, bedB.Version)
return err
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
turn := history(t, s, owner, g.ID)[0]
if turn.Source != domain.SourceAgent || len(revisionsOf(t, s, turn.ID)) != 2 {
t.Fatalf("expected one agent change set with 2 revisions: %+v", turn)
}
// Someone edits bed A afterwards.
cur, _ := s.store.GetObject(ctx, bedA.ID)
if _, err := s.UpdateObject(ctx, owner, bedA.ID, ObjectPatch{Name: strPtr("Touched")}, cur.Version); err != nil {
t.Fatalf("later edit: %v", err)
}
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
if err != nil {
t.Fatalf("RevertChangeSet: %v", err)
}
if len(conflicts) != 1 {
t.Fatalf("got %d conflicts, want 1: %+v", len(conflicts), conflicts)
}
if conflicts[0].EntityID != bedA.ID || conflicts[0].Reason != domain.ConflictChanged {
t.Errorf("unexpected conflict %+v", conflicts[0])
}
if conflicts[0].Name != "Touched" {
t.Errorf("conflict should name the entity as it stands now, got %q", conflicts[0].Name)
}
// A was left alone; B still reverted.
a, _ := s.store.GetObject(ctx, bedA.ID)
if a.XCM != 600 || a.Name != "Touched" {
t.Errorf("conflicted object was modified: %+v", a)
}
b, _ := s.store.GetObject(ctx, bedB.ID)
if b.XCM != bedB.XCM {
t.Errorf("bed B should have reverted to %v, got %v", bedB.XCM, b.XCM)
}
}
// TestRevertRestoresDeletedObjectWithItsPlantings covers the cascade: deleting an
// object takes its plops with it at the FK level, where the service never sees
// them. If they aren't snapshotted the delete is listed in history but can't
// actually be undone.
func TestRevertRestoresDeletedObjectWithItsPlantings(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 20,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if err := s.DeleteObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("DeleteObject: %v", err)
}
del := history(t, s, owner, g.ID)[0]
revs := revisionsOf(t, s, del.ID)
if len(revs) != 2 {
t.Fatalf("delete recorded %d revisions, want 2 (the bed and its plop)", len(revs))
}
if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
}
back, err := s.store.GetObject(ctx, bed.ID)
if err != nil {
t.Fatalf("bed not restored: %v", err)
}
if back.ID != bed.ID || back.Name != bed.Name || back.XCM != bed.XCM {
t.Errorf("restored bed differs: %+v", back)
}
restoredPlop, err := s.store.GetPlanting(ctx, plop.ID)
if err != nil {
t.Fatalf("plop not restored: %v", err)
}
if restoredPlop.ObjectID != bed.ID || restoredPlop.XCM != plop.XCM {
t.Errorf("restored plop differs: %+v", restoredPlop)
}
}
// TestRevertClearObject: "clear bed" is a bulk soft-remove, and undoing it should
// bring the whole bed back planted.
func TestRevertClearObject(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err)
}
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
n, err := s.ClearObject(ctx, owner, bed.ID)
if err != nil {
t.Fatalf("ClearObject: %v", err)
}
if n != len(before) {
t.Fatalf("cleared %d of %d", n, len(before))
}
if active, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID); len(active) != 0 {
t.Fatalf("%d plops still active after clear", len(active))
}
clear := history(t, s, owner, g.ID)[0]
if _, conflicts, err := s.RevertChangeSet(ctx, owner, clear.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
}
after, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
if len(after) != len(before) {
t.Errorf("%d plops active after undo, want %d", len(after), len(before))
}
}
// TestRevertPermissions: a viewer may read history but not undo; a stranger sees
// neither (existence stays masked).
func TestRevertPermissions(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
viewer := seedUser(t, s, "[email protected]")
stranger := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("AddShare: %v", err)
}
_ = bed
cs := history(t, s, owner, g.ID)[0]
if _, _, err := s.GardenHistory(ctx, viewer, g.ID, 0, 0); err != nil {
t.Errorf("a viewer should be able to read history: %v", err)
}
if _, _, err := s.RevertChangeSet(ctx, viewer, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer revert err = %v, want ErrForbidden", err)
}
if _, _, err := s.GardenHistory(ctx, stranger, g.ID, 0, 0); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger history err = %v, want ErrNotFound", err)
}
if _, _, err := s.RevertChangeSet(ctx, stranger, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger revert err = %v, want ErrNotFound", err)
}
}
// TestWithChangeSetRecordsWhatCommittedOnFailure — a turn that fails partway has
// still committed the mutations it got through, because this scope buffers
// HISTORY, not data. Dropping the buffer would leave those changes with no
// history and no way to undo them, which is exactly the situation undo exists
// for. So they're recorded, marked as partial, and the failure is still reported.
func TestWithChangeSetRecordsWhatCommittedOnFailure(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
sentinel := errors.New("boom")
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Half a turn"},
func(ctx context.Context) error {
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(600)}, bed.Version); err != nil {
return err
}
return sentinel
})
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want the sentinel", err)
}
// The move really happened, so it must be in history and undoable.
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != 600 {
t.Fatalf("the committed move was rolled back? x = %v", o.XCM)
}
sets := history(t, s, owner, g.ID)
partial := sets[0]
if !strings.Contains(partial.Summary, "failed partway") {
t.Errorf("summary = %q, want it to say the turn failed partway", partial.Summary)
}
if len(revisionsOf(t, s, partial.ID)) != 1 {
t.Errorf("expected the one committed mutation to be recorded")
}
if _, conflicts, err := s.RevertChangeSet(ctx, owner, partial.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", err, conflicts)
}
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != bed.XCM {
t.Errorf("undo of the partial turn didn't restore x: %v", o.XCM)
}
}
// TestChangeSetSkippedWhenNothingChanged — a scope that mutates nothing writes no
// change set, so history isn't padded with empty entries.
func TestChangeSetSkippedWhenNothingChanged(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
before := len(history(t, s, owner, g.ID))
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Looked around"},
func(context.Context) error { return nil })
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs != nil {
t.Errorf("expected no change set, got %+v", cs)
}
if got := len(history(t, s, owner, g.ID)); got != before {
t.Errorf("history grew by %d", got-before)
}
}
// TestGardenMetadataRevert covers the third entity type.
func TestGardenMetadataRevert(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
if _, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
Name: "Renamed", WidthCM: 1200, HeightCM: 1000, UnitPref: domain.UnitImperial,
}, g.Version); err != nil {
t.Fatalf("UpdateGarden: %v", err)
}
cs := history(t, s, owner, g.ID)[0]
if _, conflicts, err := s.RevertChangeSet(ctx, owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
}
back, _ := s.store.GetGarden(ctx, g.ID)
if back.Name != g.Name || back.WidthCM != g.WidthCM {
t.Errorf("garden not restored: %+v", back)
}
// MyRole is computed, never stored — the snapshot must not have carried it
// back into the row.
if back.MyRole != "" {
t.Errorf("MyRole leaked into the stored row: %q", back.MyRole)
}
}
// TestPlanRevertOrdersRestoresParentFirst pins the ordering the FKs depend on,
// rather than trusting reverse-seq to get it right by luck.
func TestPlanRevertOrdersRestoresParentFirst(t *testing.T) {
revs := []domain.Revision{
{Seq: 1, EntityType: domain.EntityPlanting, EntityID: 10, Op: domain.OpDelete},
{Seq: 2, EntityType: domain.EntityObject, EntityID: 1, Op: domain.OpDelete},
{Seq: 3, EntityType: domain.EntityObject, EntityID: 2, Op: domain.OpCreate},
{Seq: 4, EntityType: domain.EntityPlanting, EntityID: 11, Op: domain.OpCreate},
{Seq: 5, EntityType: domain.EntityObject, EntityID: 3, Op: domain.OpUpdate},
}
got := planRevert(revs)
want := []struct {
entity string
id int64
}{
{domain.EntityObject, 1}, // restore parents first
{domain.EntityPlanting, 10}, // then children
{domain.EntityObject, 3}, // then updates
{domain.EntityPlanting, 11}, // then delete created children
{domain.EntityObject, 2}, // and only then their parents
}
if len(got) != len(want) {
t.Fatalf("planned %d revisions, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].EntityType != w.entity || got[i].EntityID != w.id {
t.Errorf("step %d = %s/%d, want %s/%d", i, got[i].EntityType, got[i].EntityID, w.entity, w.id)
}
}
}
// TestSnapshotsCaptureVersion — the revert guard compares against the version in
// the after-snapshot, so it has to actually be there.
func TestSnapshotsCaptureVersion(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
revs := revisionsOf(t, s, history(t, s, owner, g.ID)[0].ID)
if len(revs) != 1 || revs[0].After == nil {
t.Fatalf("unexpected revisions %+v", revs)
}
var snap domain.GardenObject
if err := json.Unmarshal([]byte(*revs[0].After), &snap); err != nil {
t.Fatalf("snapshot isn't a garden object: %v", err)
}
if snap.Version != bed.Version || snap.ID != bed.ID {
t.Errorf("snapshot = %+v, want version %d id %d", snap, bed.Version, bed.ID)
}
}
func f64Ptr(v float64) *float64 { return &v }
// TestRevertChangeSetTouchingOneEntityTwice — an agent turn that moves a bed and
// then renames it holds two revisions for the same object. Each inverse bumps
// that row's version, so from the second one on the snapshot's version no longer
// matches the live row. Without tracking what the revert itself just wrote, the
// guard flags our own work as somebody else's edit and the undo half-fails.
func TestRevertChangeSetTouchingOneEntityTwice(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Two edits"},
func(ctx context.Context) error {
moved, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(600)}, bed.Version)
if err != nil {
return err
}
_, err = s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("Renamed")}, moved.Version)
return err
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
turn := history(t, s, owner, g.ID)[0]
if len(revisionsOf(t, s, turn.ID)) != 2 {
t.Fatalf("expected 2 revisions for the same object")
}
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
if err != nil {
t.Fatalf("RevertChangeSet: %v", err)
}
if len(conflicts) != 0 {
t.Fatalf("the revert conflicted with its own work: %+v", conflicts)
}
back, _ := s.store.GetObject(ctx, bed.ID)
if back.XCM != bed.XCM || back.Name != bed.Name {
t.Errorf("both edits should have been undone, got x=%v name=%q", back.XCM, back.Name)
}
}
// TestRevertOfObjectCreateRefusesWhenPlanted — undoing "added a bed" would
// cascade away anything planted in it since. Those plops would be snapshotted and
// so recoverable, but deleting someone's plants as a side effect of an unrelated
// undo should be reported, not performed quietly.
func TestRevertOfObjectCreateRefusesWhenPlanted(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
bed := seedBed(t, s, owner, g.ID)
create := history(t, s, owner, g.ID)[0]
plant := seedOwnPlant(t, s, owner, 15)
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
cs, conflicts, err := s.RevertChangeSet(ctx, owner, create.ID, domain.SourceUI)
if err != nil {
t.Fatalf("RevertChangeSet: %v", err)
}
if len(conflicts) != 1 || conflicts[0].EntityID != bed.ID {
t.Fatalf("expected one conflict naming the bed, got %+v", conflicts)
}
if cs != nil {
t.Errorf("nothing should have been reverted, got change set %+v", cs)
}
if _, err := s.store.GetObject(ctx, bed.ID); err != nil {
t.Errorf("the planted bed was deleted anyway: %v", err)
}
}
// TestHistoryDoesNotDuplicateMultiplyRevertedEntries — more than one change set
// can point at the same target (undo, redo, undo again). Looking that up with a
// LEFT JOIN would emit one duplicate row per revert and silently corrupt the
// page, so it is a scalar subquery instead. Written against the store because
// getting a target legitimately reverted twice through the service is hard: the
// second attempt conflicts, which is itself the correct behaviour.
func TestHistoryDoesNotDuplicateMultiplyRevertedEntries(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
target := history(t, s, owner, g.ID) // garden create isn't recorded; start empty
if len(target) != 0 {
t.Fatalf("expected an empty history, got %+v", target)
}
base, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: g.ID, ActorID: owner, Source: domain.SourceUI, Summary: "Base",
}, []domain.Revision{{EntityType: domain.EntityGarden, EntityID: g.ID, Op: domain.OpUpdate}})
if err != nil {
t.Fatalf("WriteChangeSet: %v", err)
}
for i := 0; i < 2; i++ {
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: g.ID, ActorID: owner, Source: domain.SourceUI, Summary: "Undo", RevertsID: &base.ID,
}, []domain.Revision{{EntityType: domain.EntityGarden, EntityID: g.ID, Op: domain.OpUpdate}}); err != nil {
t.Fatalf("WriteChangeSet revert %d: %v", i, err)
}
}
sets := history(t, s, owner, g.ID)
if len(sets) != 3 {
t.Fatalf("got %d change sets, want 3 — a join would have duplicated the base row: %+v", len(sets), sets)
}
seen := map[int64]int{}
for _, cs := range sets {
seen[cs.ID]++
}
for id, n := range seen {
if n != 1 {
t.Errorf("change set %d appears %d times", id, n)
}
}
for _, cs := range sets {
if cs.ID == base.ID && (cs.RevertedByID == nil || *cs.RevertedByID == 0) {
t.Error("the base change set should be marked as reverted")
}
}
}
// TestRevertSourceIsRecorded — an agent undoing its own work must be
// distinguishable from a person clicking undo, or the history badge lies.
func TestRevertSourceIsRecorded(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
move := history(t, s, owner, g.ID)[0]
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceAgent)
if err != nil {
t.Fatalf("revert: %v", err)
}
if undo.Source != domain.SourceAgent {
t.Errorf("source = %q, want agent", undo.Source)
}
if _, _, err := s.RevertChangeSet(ctx, owner, move.ID, "nonsense"); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("an unknown source should be rejected, got %v", err)
}
}
// TestClearObjectOnlyClearsWhatItSnapshotted — the clear targets the exact ids it
// read, so a plop created between the read and the UPDATE can't be removed
// without a revision to undo it by.
func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err)
}
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
n, err := s.ClearObject(ctx, owner, bed.ID)
if err != nil {
t.Fatalf("ClearObject: %v", err)
}
clear := history(t, s, owner, g.ID)[0]
revs := revisionsOf(t, s, clear.ID)
// Every row the clear touched has a revision; the count and the revisions agree.
if n != len(before) || len(revs) != n {
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
}
}
// TestRevertResultCarriesItsCounts — found by using the thing.
//
// commitScope returns the freshly inserted row, which carries no tally. A caller
// receiving a change set with empty Counts cannot tell "nothing was reverted"
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
// wrong way: a successful undo reported "nothing left to undo", which is the
// worst possible thing to say about an action that just worked.
//
// It also pins the cross-layer contract that fix created. countRevisions tallies
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
// the totals, which would agree even if the groupings had diverged.
func TestRevertResultCarriesItsCounts(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err)
}
// A second, different kind of change, so the breakdown has more than one row
// to get wrong.
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
t.Fatalf("rename: %v", err)
}
sets := history(t, s, owner, g.ID)
rename, fill := sets[0], sets[1]
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
}
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
if undo == nil {
t.Fatal("a revert that did work returned no change set")
}
if len(undo.Counts) == 0 {
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
}
}
// Undoing a fill deletes every plop it created, so the tallies must match.
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
}
// The SQL tally and the Go tally must agree, row for row.
listed := history(t, s, owner, g.ID)
byID := map[int64][]domain.ChangeCount{}
for _, cs := range listed {
byID[cs.ID] = cs.Counts
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
fromSQL, ok := byID[undo.ID]
if !ok {
t.Fatalf("revert %d is missing from the history list", undo.ID)
}
if !sameCounts(undo.Counts, fromSQL) {
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
undo.ID, undo.Counts, fromSQL)
}
}
}
// sameCounts compares two tallies regardless of order.
func sameCounts(a, b []domain.ChangeCount) bool {
if len(a) != len(b) {
return false
}
index := func(cs []domain.ChangeCount) map[string]int {
m := map[string]int{}
for _, c := range cs {
m[c.EntityType+"/"+c.Op] = c.N
}
return m
}
ia, ib := index(a), index(b)
for k, v := range ia {
if ib[k] != v {
return false
}
}
return true
}
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
func countsTotal(counts []domain.ChangeCount) int {
n := 0
for _, c := range counts {
n += c.N
}
return n
}
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
//
// The failure path was detached from cancellation; the SUCCESS path was not. An
// agent turn whose client disconnects can still COMPLETE — and then the success
// path committed with a dead context, the write failed, and real changes were
// left with no change set and no way to undo them. Found live with 18 plantings
// behind no history at all.
//
// Nothing about a commit needs the caller to still be there: by the time it
// runs, the data it describes has already been written.
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx, cancel := context.WithCancel(context.Background())
before := len(history(t, s, owner, g.ID))
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
return err
}
cancel() // the client disconnects, mid-turn, after the work landed
return nil
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs == nil {
t.Fatal("the turn changed things but produced no change set")
}
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
}
if after[0].Summary != "plant beans in the second bed" {
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
}
// And it's undoable, which is the entire point.
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
}
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
if len(active) != 0 {
t.Errorf("%d plantings survived the undo", len(active))
}
}
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
// path virtually every mutation takes.
//
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
// between the row landing and the change set being written, that change is
// orphaned exactly as an agent turn's was — and this path is used far more.
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
before := len(history(t, s, owner, g.ID))
// The row write and the history write share this context; cancelling after
// the mutation returns is the client hanging up mid-request.
ctx, cancel := context.WithCancel(context.Background())
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
cancel()
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
}
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
}
back, _ := s.store.GetObject(context.Background(), bed.ID)
if back.XCM != bed.XCM {
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
}
}
// TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden — a scope is for ONE
// garden, but nothing stops a mutation inside it from touching another garden
// the actor can edit (the agent, pointed at "my other garden"). Those revisions
// belong to the garden they changed, as their own change set carrying the
// scope's source and run id — not to the open scope, whose undo would then
// quietly revert rows in a garden nobody is looking at.
func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
a := seedGarden(t, s, owner)
b := seedGarden(t, s, owner)
bedB, err := s.CreateObject(ctx, owner, b.ID, ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("bed: %v", err)
}
beforeB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0)
run := "run-1"
cs, err := s.WithChangeSet(ctx, owner, a.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "a turn on A", AgentRunID: &run},
func(ctx context.Context) error {
name := "Renamed from A"
_, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{Name: &name}, bedB.Version)
return err
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs != nil {
t.Errorf("the scope on A wrote change set %d, but nothing in A changed", cs.ID)
}
afterB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0)
if len(afterB) != len(beforeB)+1 {
t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB))
}
got := afterB[0]
if got.Source != domain.SourceAgent || got.AgentRunID == nil || *got.AgentRunID != run {
t.Errorf("B's entry = source %q run %v, want the scope's (agent, %q)", got.Source, got.AgentRunID, run)
}
if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
}
if d, err := s.DescribeGarden(ctx, owner, b.ID); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err)
}
}