package service import ( "context" "encoding/json" "errors" "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, "a@example.com") 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, "a@example.com") 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) 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, "a@example.com") 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); 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, "a@example.com") 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) 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) 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, "a@example.com") 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) 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, "a@example.com") 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); 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, "a@example.com") 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); 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, "a@example.com") viewer := seedUser(t, s, "v@example.com") stranger := seedUser(t, s, "s@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) ctx := context.Background() if _, err := s.AddShare(ctx, owner, g.ID, "v@example.com", 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); !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) { 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) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") 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 { 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) } if got := len(history(t, s, owner, g.ID)); got != before { t.Errorf("failed turn left %d change sets behind", got-before) } } // 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, "a@example.com") 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, "a@example.com") 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); 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, "a@example.com") 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 }