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
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -96,7 +97,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
|
||||
}
|
||||
|
||||
// Reverting removes all N and leaves the bed as it was.
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID)
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("RevertChangeSet: %v", err)
|
||||
}
|
||||
@@ -127,7 +128,7 @@ func TestRevertRestoresObjectGeometry(t *testing.T) {
|
||||
}
|
||||
|
||||
sets := history(t, s, owner, g.ID)
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID); err != nil || len(conflicts) != 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -158,7 +159,7 @@ func TestRevertOfRevertRestoresTheChange(t *testing.T) {
|
||||
}
|
||||
move := history(t, s, owner, g.ID)[0]
|
||||
|
||||
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID)
|
||||
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("revert: %v", err)
|
||||
}
|
||||
@@ -169,7 +170,7 @@ func TestRevertOfRevertRestoresTheChange(t *testing.T) {
|
||||
t.Fatalf("undo didn't restore x: %v", o.XCM)
|
||||
}
|
||||
|
||||
redo, conflicts, err := s.RevertChangeSet(ctx, owner, undo.ID)
|
||||
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)
|
||||
}
|
||||
@@ -236,7 +237,7 @@ func TestRevertConflictsOnLaterEdit(t *testing.T) {
|
||||
t.Fatalf("later edit: %v", err)
|
||||
}
|
||||
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID)
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("RevertChangeSet: %v", err)
|
||||
}
|
||||
@@ -289,7 +290,7 @@ func TestRevertRestoresDeletedObjectWithItsPlantings(t *testing.T) {
|
||||
t.Fatalf("delete recorded %d revisions, want 2 (the bed and its plop)", len(revs))
|
||||
}
|
||||
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID); err != nil || len(conflicts) != 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -336,7 +337,7 @@ func TestRevertClearObject(t *testing.T) {
|
||||
}
|
||||
|
||||
clear := history(t, s, owner, g.ID)[0]
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, clear.ID); err != nil || len(conflicts) != 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)
|
||||
@@ -365,27 +366,29 @@ func TestRevertPermissions(t *testing.T) {
|
||||
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); !errors.Is(err, domain.ErrForbidden) {
|
||||
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); !errors.Is(err, domain.ErrNotFound) {
|
||||
if _, _, err := s.RevertChangeSet(ctx, stranger, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("stranger revert err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithChangeSetDiscardsOnFailure: history records operations that happened,
|
||||
// not operations that were attempted.
|
||||
func TestWithChangeSetDiscardsOnFailure(t *testing.T) {
|
||||
// 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()
|
||||
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
sentinel := errors.New("boom")
|
||||
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Half a turn"},
|
||||
func(ctx context.Context) error {
|
||||
@@ -397,8 +400,24 @@ func TestWithChangeSetDiscardsOnFailure(t *testing.T) {
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want the sentinel", err)
|
||||
}
|
||||
if got := len(history(t, s, owner, g.ID)); got != before {
|
||||
t.Errorf("failed turn left %d change sets behind", got-before)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +456,7 @@ func TestGardenMetadataRevert(t *testing.T) {
|
||||
t.Fatalf("UpdateGarden: %v", err)
|
||||
}
|
||||
cs := history(t, s, owner, g.ID)[0]
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, cs.ID); err != nil || len(conflicts) != 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)
|
||||
@@ -504,3 +523,184 @@ func TestSnapshotsCaptureVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user