Build image / build-and-push (push) Successful in 5s
Gadfly's point stands: countRevisions reproduces in Go what ListChangeSets does in SQL, and nothing was holding the two together. Its sibling finding is the same problem seen from the test side — the test compared only TOTALS, which would agree even if the two groupings had diverged completely. Both ends now name the contract, and the test compares the full per-(entity, op) breakdown row for row, across two reverts of different shapes so there is more than one row to get wrong. That turns "someone will remember to keep these in step" into something CI notices. The duplication itself stays. A change set that has just been written has no rows to GROUP BY yet, so the alternative to counting in Go is a second round trip to count what we are already holding. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
808 lines
29 KiB
Go
808 lines
29 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.
|
|
//
|
|
// 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); 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
|
|
}
|