| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/control" |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/sessioninbox" |
| 11 | ) |
| 12 | |
| 13 | func TestInboxReceiptSurvivesLocalTabReopen(t *testing.T) { |
| 14 | isolateDesktopUserDirs(t) |
| 15 | dir := t.TempDir() |
| 16 | path := filepath.Join(dir, "session.jsonl") |
| 17 | ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard}) |
| 18 | if err := ctrl.SetInboxPaused(true); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | a := &App{tabs: map[string]*WorkspaceTab{"old": {ID: "old", Ctrl: ctrl, SessionPath: path, SessionGeneration: 1, Ready: true}}} |
| 22 | target, err := a.CaptureInboxTarget("old", path) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | receipt, err := a.EnqueueInboxFollowupForTarget(target, "original", "original", nil, "original-key") |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | ctrl.Close() |
| 31 | reopened := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard}) |
| 32 | defer reopened.Close() |
| 33 | a.tabs = map[string]*WorkspaceTab{"new": {ID: "new", Ctrl: reopened, SessionPath: path, SessionGeneration: 1, Ready: true}} |
| 34 | actual, err := a.LookupInboxFollowupForTarget(target, "original-key") |
| 35 | if err != nil || actual.ItemID != receipt.ItemID { |
| 36 | t.Fatalf("reopened receipt = %+v, %v", actual, err) |
| 37 | } |
| 38 | if _, err := a.EnqueueInboxFollowupForTarget(target, "repeat", "repeat", nil, "repeat"); err == nil { |
| 39 | t.Fatal("old write fence accepted new tab") |
| 40 | } |
| 41 | if got := len(reopened.InboxSnapshot().Items); got != 1 { |
| 42 | t.Fatalf("replayed submission: %d items", got) |
| 43 | } |
| 44 | a.detachedSessions = map[string]*WorkspaceTab{sessionRuntimeKey(path): a.tabs["new"]} |
| 45 | a.tabs = nil |
| 46 | if actual, err := a.LookupInboxFollowupForTarget(target, "original-key"); err != nil || actual.ItemID != receipt.ItemID { |
| 47 | t.Fatalf("detached owner receipt = %+v, %v", actual, err) |
| 48 | } |
| 49 | a.detachedSessions = nil |
| 50 | if _, err := a.LookupInboxFollowupForTarget(target, "original-key"); err == nil || len(a.tabs)+len(a.detachedSessions) != 0 { |
| 51 | t.Fatal("lookup created an owner for an unavailable session") |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | type receiptOwnerHook struct { |
| 56 | control.SessionAPI |
| 57 | beforeReturn func() |
| 58 | } |
| 59 | |
| 60 | func (c *receiptOwnerHook) LookupInboxReceiptForSession(_, _ string) (sessioninbox.InboxReceipt, bool, error) { |
| 61 | c.beforeReturn() |
| 62 | return sessioninbox.InboxReceipt{ItemID: "original"}, true, nil |
| 63 | } |
| 64 | |
| 65 | func TestInboxReceiptRejectsLocalOwnerChangedDuringRead(t *testing.T) { |
| 66 | isolateDesktopUserDirs(t) |
| 67 | dir := t.TempDir() |
| 68 | path := filepath.Join(dir, "session.jsonl") |
| 69 | ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard}) |
| 70 | defer ctrl.Close() |
| 71 | for _, change := range []string{"generation", "controller", "path", "removed", "readonly"} { |
| 72 | t.Run(change, func(t *testing.T) { |
| 73 | a := &App{} |
| 74 | hook := &receiptOwnerHook{SessionAPI: ctrl} |
| 75 | tab := &WorkspaceTab{ID: "new", Ctrl: hook, SessionPath: path, SessionGeneration: 2} |
| 76 | a.tabs = map[string]*WorkspaceTab{"new": tab} |
| 77 | hook.beforeReturn = func() { |
| 78 | a.mu.Lock() |
| 79 | defer a.mu.Unlock() |
| 80 | switch change { |
| 81 | case "generation": |
| 82 | tab.SessionGeneration++ |
| 83 | case "controller": |
| 84 | tab.Ctrl = ctrl |
| 85 | case "path": |
| 86 | tab.SessionPath = filepath.Join(dir, "other.jsonl") |
| 87 | case "removed": |
| 88 | delete(a.tabs, "new") |
| 89 | case "readonly": |
| 90 | tab.ReadOnly = true |
| 91 | } |
| 92 | } |
| 93 | if got, err := a.LookupInboxFollowupForTarget(InboxTargetView{TabID: "old", SessionPath: path}, "original"); err == nil || got.ItemID != "" { |
| 94 | t.Fatalf("stale receipt escaped %s fence: %+v %v", change, got, err) |
| 95 | } |
| 96 | }) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestInboxReceiptRebindsRemoteTabWithoutReplaying(t *testing.T) { |
| 101 | isolateDesktopUserDirs(t) |
| 102 | reads := 0 |
| 103 | client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 104 | if req.Method != http.MethodGet || req.URL.Query().Get("session") != runtimeRemoteTestPath || req.URL.Query().Get("key") != "original" { |
| 105 | t.Errorf("wrong receipt route: %s %s", req.Method, req.URL.Path) |
| 106 | } |
| 107 | reads++ |
| 108 | return remoteRuntimeTestResponse(req, 200, `{"itemId":"original","position":0,"disposition":"idempotent_hit","paused":false}`), nil |
| 109 | })} |
| 110 | a, tab := remoteRuntimeTestApp(client) |
| 111 | target, err := a.CaptureInboxTarget(tab.id, runtimeRemoteTestPath) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | delete(a.remoteTabs, tab.id) |
| 116 | tab.id = "reopened" |
| 117 | tab.gen++ |
| 118 | a.remoteTabs[tab.id] = tab |
| 119 | if got, err := a.LookupInboxFollowupForTarget(target, "original"); err != nil || got.ItemID != "original" { |
| 120 | t.Fatalf("lookup = %+v %v", got, err) |
| 121 | } |
| 122 | if reads != 1 { |
| 123 | t.Fatalf("reads = %d", reads) |
| 124 | } |
| 125 | if _, err := a.EnqueueInboxFollowupForTarget(target, "repeat", "repeat", nil, "repeat"); err == nil { |
| 126 | t.Fatal("old write target was rebound") |
| 127 | } |
| 128 | tab.ref.Workspace = "different-workspace" |
| 129 | if _, err := a.LookupInboxFollowupForTarget(target, "original"); err == nil || reads != 1 { |
| 130 | t.Fatal("receipt crossed workspace identity") |
| 131 | } |
| 132 | } |
| 133 |