Files
pansy/internal/service/revisions_test.go
T
steveandClaude Opus 4.8 2dd9f2f04f
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on revision history
Six real bugs, three of which would have broken the feature's central promise.

The worst was that a failed operation threw away the history for changes that
had already committed. WithChangeSet buffers HISTORY, not data — the store
writes inside fn commit as they go — so discarding the buffer on error left real
mutations with no change set and no way to undo them. That is exactly the
situation undo exists for: an agent turn that did half a thing and then failed.
Now the buffer is written, the summary says the operation failed partway, and
the error is still returned. RevertChangeSet does the same for a revert that
fails mid-loop. The partial state is still partial, but it is visible and
undoable instead of orphaned.

versionGuard falsely conflicted whenever one change set held more than one
revision for the same entity — an agent turn that moves a bed and then renames
it. Each inverse bumps the row's version, so from the second one on the
snapshot's version no longer matched the live row and the revert flagged its own
work as somebody else's edit. RevertChangeSet now tracks what it has written and
the guard compares against that.

ListChangeSets found "was this reverted?" with a LEFT JOIN, which emits one
duplicate row per revert once a change set has been reverted more than once
(undo, redo, undo again). Now a scalar subquery.

Reverting an object creation cascade-deleted anything planted in that bed since,
quietly. The plops were snapshotted so it was recoverable, but deleting
someone's plants as a side effect of an unrelated undo should be reported. It
now conflicts and leaves the bed alone.

ClearObject cleared by predicate and snapshotted by a separate read, so a plop
created between the two was removed with no revision to undo it by. It now
clears exactly the ids it read. It also returned an error when only the
post-clear re-read failed, which told the caller a clear had failed after it had
already applied — inviting a retry of an applied operation. That path now logs
the history gap and reports success, matching how record() treats its own write
failures.

RestoreObject/RestorePlanting could lose the race between the existence check
and the insert and surface a raw constraint error. The revert now re-checks and
reports ConflictExists, which is what that condition means.

RevertChangeSet takes a source, so an agent undoing its own work is
distinguishable from a person clicking undo — the whole point of the badge.

Refactoring: the three revert bodies repeated a load/guard/unsnapshot/update
skeleton (flagged by 3 of 5 models). They are now one generic revertEntity over
a small per-type op table, so the ordering, guards and conflict reporting cannot
drift apart. The API's view structs duplicated domain types that already carried
every field, against the package's direct-JSON convention — dropped. intQuery
moved next to the other request helpers. planRevert's comment said three passes
where the code runs five. A revert where every revision is already a no-op now
answers 200 rather than 201 with a null change set.

Six new tests, one per bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:06:43 -04:00

707 lines
25 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)
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); 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); 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))
}
}