Build image / build-and-push (push) Successful in 5s
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
154 lines
5.8 KiB
Go
154 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
func historyPath(gardenID int64) string {
|
|
return "/api/v1/gardens/" + strconv.FormatInt(gardenID, 10) + "/history"
|
|
}
|
|
|
|
func revertPath(changeSetID int64) string {
|
|
return "/api/v1/change-sets/" + strconv.FormatInt(changeSetID, 10) + "/revert"
|
|
}
|
|
|
|
// TestHistoryAndRevertAPI walks the whole loop over HTTP: a mutation shows up in
|
|
// history without anyone asking for it, reverting it answers 201 with the new
|
|
// change set, and the change is actually gone from /full.
|
|
func TestHistoryAndRevertAPI(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
gid := createGardenAPI(t, r, cookie, "G")
|
|
|
|
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
|
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
|
|
}, cookie)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
// The create landed in history with no explicit change set anywhere.
|
|
w = doJSON(t, r, http.MethodGet, historyPath(gid), nil, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
body := decodeMap(t, w.Body.Bytes())
|
|
sets, _ := body["changeSets"].([]any)
|
|
if len(sets) != 1 {
|
|
t.Fatalf("got %d change sets, want 1: %s", len(sets), w.Body.String())
|
|
}
|
|
if body["hasMore"].(bool) {
|
|
t.Error("hasMore should be false for a single-entry history")
|
|
}
|
|
entry := sets[0].(map[string]any)
|
|
if entry["summary"] != "Added North Bed" {
|
|
t.Errorf("summary = %v", entry["summary"])
|
|
}
|
|
if entry["source"] != "ui" || entry["actorName"] == "" {
|
|
t.Errorf("unexpected entry: %+v", entry)
|
|
}
|
|
counts, _ := entry["counts"].([]any)
|
|
if len(counts) != 1 {
|
|
t.Fatalf("counts = %+v", entry["counts"])
|
|
}
|
|
|
|
// Revert it: 201, and the new change set points back at the original.
|
|
csID := int64(entry["id"].(float64))
|
|
w = doJSON(t, r, http.MethodPost, revertPath(csID), nil, cookie)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("revert: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
rev := decodeMap(t, w.Body.Bytes())
|
|
cs := rev["changeSet"].(map[string]any)
|
|
if int64(cs["revertsId"].(float64)) != csID {
|
|
t.Errorf("revertsId = %v, want %d", cs["revertsId"], csID)
|
|
}
|
|
if conflicts, _ := rev["conflicts"].([]any); len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %+v", conflicts)
|
|
}
|
|
|
|
// The object is gone from the editor payload.
|
|
w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie)
|
|
full := decodeMap(t, w.Body.Bytes())
|
|
if objects, _ := full["objects"].([]any); len(objects) != 0 {
|
|
t.Errorf("%d objects survived the revert", len(objects))
|
|
}
|
|
}
|
|
|
|
// TestRevertConflictAPI: a partial revert answers 409 and names what it skipped,
|
|
// because "2 of 3 undone, the north bed was edited since" is the only useful
|
|
// thing to say — a bare failure would be a lie about what happened.
|
|
func TestRevertConflictAPI(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
gid := createGardenAPI(t, r, cookie, "G")
|
|
|
|
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
|
"kind": "bed", "name": "Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
|
|
}, cookie)
|
|
obj := decodeMap(t, w.Body.Bytes())
|
|
objectID := int64(obj["id"].(float64))
|
|
version := int64(obj["version"].(float64))
|
|
|
|
// Move it (change set #2), then edit it again (change set #3).
|
|
w = doJSON(t, r, http.MethodPatch, objectPath(objectID), map[string]any{
|
|
"xCm": 300, "version": version,
|
|
}, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("move: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
version = int64(decodeMap(t, w.Body.Bytes())["version"].(float64))
|
|
w = doJSON(t, r, http.MethodPatch, objectPath(objectID), map[string]any{
|
|
"name": "Renamed", "version": version,
|
|
}, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("rename: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Undoing the move now conflicts: the row changed after it.
|
|
w = doJSON(t, r, http.MethodGet, historyPath(gid), nil, cookie)
|
|
sets := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
|
moveID := int64(sets[1].(map[string]any)["id"].(float64)) // newest first: rename, move, create
|
|
|
|
w = doJSON(t, r, http.MethodPost, revertPath(moveID), nil, cookie)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("revert: status %d, want 409, body %s", w.Code, w.Body.String())
|
|
}
|
|
conflicts := decodeMap(t, w.Body.Bytes())["conflicts"].([]any)
|
|
if len(conflicts) != 1 {
|
|
t.Fatalf("conflicts = %+v", conflicts)
|
|
}
|
|
c := conflicts[0].(map[string]any)
|
|
if c["reason"] != "changed" || c["name"] != "Renamed" {
|
|
t.Errorf("conflict = %+v", c)
|
|
}
|
|
|
|
// The object was left exactly alone.
|
|
w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie)
|
|
objects := decodeMap(t, w.Body.Bytes())["objects"].([]any)
|
|
o := objects[0].(map[string]any)
|
|
if o["xCm"].(float64) != 300 || o["name"] != "Renamed" {
|
|
t.Errorf("conflicted object was modified: %+v", o)
|
|
}
|
|
}
|
|
|
|
// TestHistoryRequiresAccess — an unauthenticated caller can't read history, and a
|
|
// stranger gets 404 rather than a hint that the garden exists.
|
|
func TestHistoryRequiresAccess(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
owner := registerAndCookie(t, r, "[email protected]")
|
|
gid := createGardenAPI(t, r, owner, "G")
|
|
stranger := registerAndCookie(t, r, "[email protected]")
|
|
|
|
if w := doJSON(t, r, http.MethodGet, historyPath(gid), nil, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous history status = %d, want 401", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodGet, historyPath(gid), nil, stranger); w.Code != http.StatusNotFound {
|
|
t.Errorf("stranger history status = %d, want 404", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, revertPath(1), nil, stranger); w.Code != http.StatusNotFound {
|
|
t.Errorf("stranger revert status = %d, want 404", w.Code)
|
|
}
|
|
}
|