Found by running the real thing: the agent replaced a bed of garlic with cucumbers, Undo restored the garlic correctly — and then said "Nothing left to undo — this was already reversed." The revert response carries the change set commitScope just inserted, and that row has no per-op tally: Counts is populated by the history LIST query, not by the insert. describeUndo treated a zero tally as "nothing happened", so a real undo with eight revisions behind it reported itself as a no-op. Telling someone an action did nothing when it just worked is about the worst thing a safety feature can say, because the obvious next move is to do it again. Fixed on both sides. The client now treats a NULL change set as the no-op signal, which is what the server actually means by it — an empty tally is not the same thing and must not be read as one. And RevertChangeSet now fills in the counts, so the revert response and the list read describe the same change set the same way, and a partial revert can say "2 of 3" from either. The unit test missed it because the fixture I wrote populated counts, so it was testing my assumption about the response rather than its actual shape. The new tests pin the real thing: the service asserts a revert's counts match the fill it undid AND match what the list read reports, and the client asserts an empty tally still reads as "Undone." Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
761 lines
27 KiB
Go
761 lines
27 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))
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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); err != nil {
|
|
t.Fatalf("fill: %v", err)
|
|
}
|
|
fill := history(t, s, owner, g.ID)[0]
|
|
planted := totalOf(fill.Counts)
|
|
if planted == 0 {
|
|
t.Fatal("the fill recorded no counts")
|
|
}
|
|
|
|
undo, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
|
if err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
if undo == nil {
|
|
t.Fatal("a revert that did work returned no change set")
|
|
}
|
|
if got := totalOf(undo.Counts); got != planted {
|
|
t.Errorf("revert reported %d changes, want %d — an empty tally reads as 'nothing happened'", got, planted)
|
|
}
|
|
|
|
// And the list read agrees with what the revert returned, so both halves of
|
|
// the API describe the same change set the same way.
|
|
listed := history(t, s, owner, g.ID)[0]
|
|
if listed.ID != undo.ID {
|
|
t.Fatalf("newest change set is %d, want the revert %d", listed.ID, undo.ID)
|
|
}
|
|
if totalOf(listed.Counts) != totalOf(undo.Counts) {
|
|
t.Errorf("list says %d changes, revert said %d", totalOf(listed.Counts), totalOf(undo.Counts))
|
|
}
|
|
}
|
|
|
|
func totalOf(counts []domain.ChangeCount) int {
|
|
n := 0
|
|
for _, c := range counts {
|
|
n += c.N
|
|
}
|
|
return n
|
|
}
|