| 1 | package workspacestate |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | func conflictingHistoricalVersionFixture(t *testing.T) (*Store, Operation) { |
| 15 | t.Helper() |
| 16 | store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json")) |
| 17 | if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "global", Root: t.TempDir(), Visible: true}); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | if err := store.AttachSession(t.Context(), "", "global", "original", ""); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | if err := store.RecordSource(t.Context(), SourceMapping{SourceKey: "source", Path: "/old/source", Format: "canonical", Fingerprint: "old", SessionID: "original", WorkspaceID: "global"}, Presentation{}); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | op := Operation{ID: "import-source-new", Kind: "import", Lifecycle: Active, WorkspaceID: "global", SessionIDs: []string{"reserved"}} |
| 27 | if err := store.BeginOperation(t.Context(), op); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | mapping := &SourceMapping{SourceKey: "source", Path: "/old/source", Format: "canonical", Fingerprint: "new", SessionID: "reserved", WorkspaceID: "global", extra: map[string]json.RawMessage{"futureMapping": json.RawMessage(`{"keep":true}`)}} |
| 31 | if err := store.PrepareOperationContent(t.Context(), op.ID, op.SessionIDs, mapping, &Presentation{Title: "Branch", extra: map[string]json.RawMessage{"futureTitle": json.RawMessage(`42`)}}); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if err := store.mutate(t.Context(), func(s *State) error { |
| 35 | v := s.PendingOperations[op.ID] |
| 36 | v.extra = map[string]json.RawMessage{"futureOperation": json.RawMessage(`"keep"`)} |
| 37 | s.PendingOperations[op.ID] = v |
| 38 | return nil |
| 39 | }); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | state, err := store.Load(t.Context()) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return store, state.PendingOperations[op.ID] |
| 47 | } |
| 48 | |
| 49 | func TestCommitHistoricalVersionPreservesOriginalAndBacksUp(t *testing.T) { |
| 50 | store, op := conflictingHistoricalVersionFixture(t) |
| 51 | before, _ := os.ReadFile(store.Path()) |
| 52 | state, _ := store.Load(t.Context()) |
| 53 | original := state.SourceMappings["source"] |
| 54 | if err := store.CommitHistoricalVersion(t.Context(), op, "source:review:new"); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | reopened, err := NewStore(store.Path()).Load(t.Context()) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if !reflect.DeepEqual(original, reopened.SourceMappings["source"]) || reopened.PendingOperations[op.ID].Phase != "committed" || reopened.SourceMappings["source:review:new"].SessionID != "reserved" { |
| 62 | t.Fatal("version commit changed original adoption or lost the branch") |
| 63 | } |
| 64 | backups, _ := filepath.Glob(filepath.Join(filepath.Dir(store.Path()), "historical-version-backups", "*.json")) |
| 65 | if len(backups) != 1 { |
| 66 | t.Fatalf("backups = %v", backups) |
| 67 | } |
| 68 | saved, _ := os.ReadFile(backups[0]) |
| 69 | if !bytes.Equal(saved, before) { |
| 70 | t.Fatal("backup changed original bytes") |
| 71 | } |
| 72 | after, _ := os.ReadFile(store.Path()) |
| 73 | for _, key := range []string{"futureMapping", "futureOperation", "futureTitle"} { |
| 74 | if !bytes.Contains(after, []byte(key)) { |
| 75 | t.Fatalf("lost unknown metadata %q", key) |
| 76 | } |
| 77 | } |
| 78 | // Ordinary commit replay remains idempotent for older readers/writers. |
| 79 | if err := NewStore(store.Path()).CommitOperation(t.Context(), op.ID); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | again, _ := os.ReadFile(store.Path()) |
| 83 | if !bytes.Equal(after, again) { |
| 84 | t.Fatal("committed replay rewrote state") |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestCommitHistoricalVersionRejectsChangedOrAmbiguousState(t *testing.T) { |
| 89 | for _, scenario := range []string{"operation_changed", "original_archived", "target_deleted", "target_attached", "target_reserved", "version_taken", "competing_operation", "backup_blocked", "cancelled"} { |
| 90 | t.Run(scenario, func(t *testing.T) { |
| 91 | store, observed := conflictingHistoricalVersionFixture(t) |
| 92 | if err := store.mutate(t.Context(), func(s *State) error { |
| 93 | switch scenario { |
| 94 | case "operation_changed": |
| 95 | op := s.PendingOperations[observed.ID] |
| 96 | op.ExpectedGeneration++ |
| 97 | s.PendingOperations[op.ID] = op |
| 98 | case "original_archived": |
| 99 | setLifecycle(s, "original", Archived) |
| 100 | case "target_deleted": |
| 101 | setLifecycle(s, "reserved", Deleted) |
| 102 | case "target_attached": |
| 103 | w := s.Workspaces["global"] |
| 104 | w.SessionIDs = append(w.SessionIDs, "reserved") |
| 105 | s.Workspaces[w.ID] = w |
| 106 | case "target_reserved": |
| 107 | s.PendingCreates["reserved"] = PendingCreate{OperationID: "other", SessionID: "reserved", WorkspaceID: "global"} |
| 108 | case "version_taken": |
| 109 | m := *observed.Mapping |
| 110 | m.SourceKey, m.SessionID = "source:review:new", "original" |
| 111 | s.SourceMappings[m.SourceKey] = m |
| 112 | case "competing_operation": |
| 113 | op := observed |
| 114 | op.ID = "another-operation" |
| 115 | s.PendingOperations[op.ID] = op |
| 116 | } |
| 117 | return nil |
| 118 | }); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if scenario == "backup_blocked" { |
| 122 | if err := os.WriteFile(filepath.Join(filepath.Dir(store.Path()), "historical-version-backups"), []byte("block directory"), 0600); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | } |
| 126 | ctx, cancel := context.WithCancel(t.Context()) |
| 127 | defer cancel() |
| 128 | if scenario == "cancelled" { |
| 129 | cancel() |
| 130 | } |
| 131 | before, _ := os.ReadFile(store.Path()) |
| 132 | err := store.CommitHistoricalVersion(ctx, observed, "source:review:new") |
| 133 | if err == nil || (scenario != "backup_blocked" && scenario != "cancelled" && !errors.Is(err, ErrMutationConflict)) { |
| 134 | t.Fatalf("unexpected result: %v", err) |
| 135 | } |
| 136 | after, _ := os.ReadFile(store.Path()) |
| 137 | if !bytes.Equal(before, after) { |
| 138 | t.Fatal("rejected repair changed registry") |
| 139 | } |
| 140 | }) |
| 141 | } |
| 142 | } |
| 143 |