1package client
2
3import (
4 "fmt"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"
14)
15
16// Route ledger (Plan 003 C4/E3), ported from main's routes_test observation
17// and ordering cases onto the broker catalogue.
18
19const routeTestRemote = "vev@box"
20
21func routeTestSnapshot(daemons ...ports.BrokerDaemonObservation) ports.BrokerSnapshot {
22 return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons}
23}
24
25func routeTestLocal(sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
26 return pickerTestLocalObservation(time.Unix(1000, 0), sessions...)
27}
28
29func routeTestRemoteHost(sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
30 return pickerTestRemoteObservation(routeTestRemote, 1, 2, time.Unix(1000, 0), sessions...)
31}
32
33func routeTestLive(name string, seed byte, lastUsed uint64, attention ...bool) catalogue.RemoteCatalogSession {
34 tabs := make([]catalogue.RemoteCatalogTab, 0, len(attention))
35 for i, bell := range attention {
36 tabs = append(tabs, pickerTestTab(fmt.Sprintf("t%d", i), fmt.Sprintf("tab%d", i), "", bell))
37 }
38 return pickerTestTabbed(name, seed, catalogue.RemoteCatalogSessionUp, lastUsed, "", tabs...)
39}
40
41func routeTestStopped(name string, seed byte, lastUsed uint64) catalogue.RemoteCatalogSession {
42 return pickerTestTabbed(name, seed, catalogue.RemoteCatalogSessionDown, lastUsed, "")
43}
44
45func routeTestActive(local bool, name string, seed byte) routeActive {
46 authority := routeAuthority{local: true}
47 if !local {
48 authority = routeAuthority{endpoint: routeTestRemote}
49 }
50 return routeActive{known: true, authority: authority, target: protocol.ExactSessionTarget{LifecycleID: pickerTestLifecycle(seed), SessionName: name}}
51}
52
53func routeEntryNames(snapshot protocol.RecentRouteSnapshot) []string {
54 names := make([]string, 0, len(snapshot.Entries))
55 for _, entry := range snapshot.Entries {
56 names = append(names, entry.Name)
57 }
58 return names
59}
60
61func TestRouteLedgerOrdering(t *testing.T) {
62 tests := []struct {
63 name string
64 visits []routeActive
65 build ports.BrokerSnapshot
66 active routeActive
67 want []string
68 }{
69 {
70 name: "live local by recency, then remote in catalogue order, then stopped",
71 build: routeTestSnapshot(
72 routeTestLocal(routeTestLive("old", 1, 1), routeTestStopped("gone", 2, 9), routeTestLive("new", 3, 5), routeTestLive("here", 4, 7)),
73 routeTestRemoteHost(routeTestLive("r2", 5, 0), routeTestLive("r1", 6, 0), routeTestStopped("rs", 7, 0)),
74 ),
75 active: routeTestActive(true, "here", 4),
76 want: []string{"new", "old", "r2", "r1", "gone", "rs"},
77 },
78 {
79 name: "this client's attach recency comes first",
80 visits: []routeActive{routeTestActive(false, "r1", 6), routeTestActive(true, "old", 1)},
81 build: routeTestSnapshot(
82 routeTestLocal(routeTestLive("old", 1, 1), routeTestLive("new", 3, 5), routeTestLive("here", 4, 7)),
83 routeTestRemoteHost(routeTestLive("r2", 5, 0), routeTestLive("r1", 6, 0)),
84 ),
85 active: routeTestActive(true, "here", 4),
86 want: []string{"old", "r1", "new", "r2"},
87 },
88 {
89 name: "broken sessions are never routes",
90 build: routeTestSnapshot(routeTestLocal(routeTestLive("ok", 1, 1), pickerTestTabbed("bad", 2, catalogue.RemoteCatalogSessionBroken, 5, ""))),
91 active: routeTestActive(true, "here", 4),
92 want: []string{"ok"},
93 },
94 }
95 for _, tt := range tests {
96 t.Run(tt.name, func(t *testing.T) {
97 ledger := newRouteLedger()
98 for _, visit := range tt.visits {
99 ledger.build(tt.build, visit)
100 }
101 snapshot, _ := ledger.build(tt.build, tt.active)
102 require.NoError(t, snapshot.Validate())
103 require.Equal(t, tt.want, routeEntryNames(snapshot))
104 require.Equal(t, tt.active.target, snapshot.ActiveEntry.Target, "the attached lifecycle is metadata only")
105 })
106 }
107}
108
109func TestRouteLedgerActivePreviousAndHome(t *testing.T) {
110 build := routeTestSnapshot(
111 routeTestLocal(routeTestLive("a", 1, 2), routeTestLive("b", 2, 1)),
112 routeTestRemoteHost(routeTestLive("r", 3, 0)),
113 )
114 tests := []struct {
115 name string
116 active routeActive
117 wantKind protocol.RouteKind
118 wantLabel string
119 wantHome bool
120 wantPrevious string
121 wantHosts []protocol.RouteKind
122 }{
123 {name: "local attachment is home", active: routeTestActive(true, "a", 1), wantKind: protocol.RouteKindLocal, wantHome: true, wantPrevious: "r", wantHosts: []protocol.RouteKind{protocol.RouteKindRemote}},
124 {name: "remote attachment offers the local host", active: routeTestActive(false, "r", 3), wantKind: protocol.RouteKindRemote, wantLabel: routeTestRemote, wantPrevious: "a", wantHosts: []protocol.RouteKind{protocol.RouteKindLocal}},
125 {name: "an unobserved attachment is synthesized", active: routeTestActive(true, "fresh", 9), wantKind: protocol.RouteKindLocal, wantHome: true, wantPrevious: "r", wantHosts: []protocol.RouteKind{protocol.RouteKindRemote}},
126 }
127 for _, tt := range tests {
128 t.Run(tt.name, func(t *testing.T) {
129 ledger := newRouteLedger()
130 // Visit the other kind first so Previous names it.
131 if tt.active.authority.local {
132 ledger.build(build, routeTestActive(false, "r", 3))
133 } else {
134 ledger.build(build, routeTestActive(true, "a", 1))
135 }
136 snapshot, changed := ledger.build(build, tt.active)
137 require.True(t, changed)
138 require.NoError(t, snapshot.Validate())
139 require.Equal(t, tt.wantKind, snapshot.ActiveEntry.Kind)
140 require.Equal(t, tt.wantLabel, snapshot.ActiveEntry.HostLabel)
141 require.Equal(t, snapshot.Active, protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation})
142 if tt.wantHome {
143 require.Equal(t, snapshot.Active, snapshot.Home)
144 } else {
145 require.True(t, snapshot.Home.IsZero())
146 }
147 previous, ok := ledger.resolve(snapshot.Previous)
148 require.True(t, ok)
149 require.Equal(t, tt.wantPrevious, previous.target.SessionName)
150 require.True(t, snapshot.ActiveEntry.Visited)
151 var visited []string
152 for _, entry := range snapshot.Entries {
153 if entry.Visited {
154 visited = append(visited, entry.Name)
155 }
156 }
157 require.Equal(t, []string{tt.wantPrevious}, visited, "only attached routes are visited")
158 kinds := make([]protocol.RouteKind, 0, len(snapshot.Hosts))
159 for _, host := range snapshot.Hosts {
160 kinds = append(kinds, host.Kind)
161 resolved, ok := ledger.resolve(protocol.RouteRef{Key: host.Key, Generation: host.Generation})
162 require.True(t, ok)
163 require.True(t, resolved.host)
164 require.NotEqual(t, tt.active.authority, resolved.authority, "the serving daemon is never a host row")
165 }
166 require.Equal(t, tt.wantHosts, kinds)
167 })
168 }
169}
170
171func TestRouteLedgerStableReferencesAndGeneration(t *testing.T) {
172 ledger := newRouteLedger()
173 active := routeTestActive(true, "here", 4)
174 first, changed := ledger.build(routeTestSnapshot(routeTestLocal(routeTestLive("a", 1, 1), routeTestLive("here", 4, 2))), active)
175 require.True(t, changed)
176 require.Equal(t, uint64(1), first.Generation)
177
178 same, changed := ledger.build(routeTestSnapshot(routeTestLocal(routeTestLive("a", 1, 1), routeTestLive("here", 4, 2))), active)
179 require.False(t, changed, "an unchanged publication never advances the generation")
180 require.Equal(t, first, same)
181
182 grown, changed := ledger.build(routeTestSnapshot(routeTestLocal(routeTestLive("a", 1, 1), routeTestLive("b", 2, 3), routeTestLive("here", 4, 2))), active)
183 require.True(t, changed)
184 require.Equal(t, uint64(2), grown.Generation)
185 require.Equal(t, first.Active, grown.Active, "a lifecycle keeps its reference")
186 require.Equal(t, first.Entries[0].Key, grown.Entries[1].Key)
187
188 replaced, _ := ledger.build(routeTestSnapshot(routeTestLocal(routeTestLive("a", 5, 1), routeTestLive("here", 4, 2))), active)
189 require.NotEqual(t, first.Entries[0].Key, replaced.Entries[0].Key, "a new lifecycle of the same name is a new reference")
190 _, ok := ledger.resolve(protocol.RouteRef{Key: first.Entries[0].Key, Generation: 1})
191 require.False(t, ok, "a retired lifecycle no longer resolves")
192}
193
194// TestRouteLedgerAttention ports main's local and remote attention
195// observations: a session carries attention while any of its tabs does, and
196// onsets are ordered as the client observed them across daemons.
197func TestRouteLedgerAttention(t *testing.T) {
198 active := routeTestActive(true, "here", 4)
199 steps := []struct {
200 name string
201 build ports.BrokerSnapshot
202 want map[string]bool
203 order []string
204 }{
205 {
206 name: "quiet",
207 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loc", 1, 1, false, false)), routeTestRemoteHost(routeTestLive("rem", 2, 0, false))),
208 want: map[string]bool{"loc": false, "rem": false},
209 },
210 {
211 name: "remote bell first",
212 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loc", 1, 1, false, false)), routeTestRemoteHost(routeTestLive("rem", 2, 0, true))),
213 want: map[string]bool{"loc": false, "rem": true},
214 order: []string{"rem"},
215 },
216 {
217 name: "then a local tab bell",
218 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loc", 1, 1, false, true)), routeTestRemoteHost(routeTestLive("rem", 2, 0, true))),
219 want: map[string]bool{"loc": true, "rem": true},
220 order: []string{"rem", "loc"},
221 },
222 {
223 name: "remote cleared, then rings again later",
224 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loc", 1, 1, false, true)), routeTestRemoteHost(routeTestLive("rem", 2, 0, false))),
225 want: map[string]bool{"loc": true, "rem": false},
226 order: []string{"loc"},
227 },
228 {
229 name: "rings again",
230 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loc", 1, 1, false, true)), routeTestRemoteHost(routeTestLive("rem", 2, 0, true))),
231 want: map[string]bool{"loc": true, "rem": true},
232 order: []string{"loc", "rem"},
233 },
234 }
235 ledger := newRouteLedger()
236 for _, step := range steps {
237 t.Run(step.name, func(t *testing.T) {
238 snapshot, _ := ledger.build(step.build, active)
239 require.NoError(t, snapshot.Validate())
240 got := map[string]bool{}
241 seq := map[string]uint64{}
242 for _, entry := range snapshot.Entries {
243 got[entry.Name] = entry.Attention
244 if entry.Attention {
245 require.NotZero(t, entry.AttentionSeq)
246 seq[entry.Name] = entry.AttentionSeq
247 } else {
248 require.Zero(t, entry.AttentionSeq)
249 }
250 }
251 require.Equal(t, step.want, got)
252 for i := 1; i < len(step.order); i++ {
253 require.Less(t, seq[step.order[i-1]], seq[step.order[i]], "%s rang before %s", step.order[i-1], step.order[i])
254 }
255 })
256 }
257}
258
259// TestRouteLedgerVisitedPrefixPrecedesRingingRoutes pins the order the
260// daemon's status history relies on: visited routes stay a prefix of Entries
261// even when an unvisited route on a higher tier rings, so ringing routes never
262// shift the jump-recent ranks of visited ones.
263func TestRouteLedgerVisitedPrefixPrecedesRingingRoutes(t *testing.T) {
264 tests := []struct {
265 name string
266 visited routeActive
267 build ports.BrokerSnapshot
268 }{
269 {
270 name: "visited stopped local before ringing live local",
271 visited: routeTestActive(true, "old", 2),
272 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true), routeTestStopped("old", 2, 1))),
273 },
274 {
275 name: "visited remote before ringing live local",
276 visited: routeTestActive(false, "r", 3),
277 build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true)), routeTestRemoteHost(routeTestLive("r", 3, 0))),
278 },
279 }
280 for _, tt := range tests {
281 t.Run(tt.name, func(t *testing.T) {
282 ledger := newRouteLedger()
283 ledger.build(tt.build, tt.visited)
284 snapshot, _ := ledger.build(tt.build, routeTestActive(true, "here", 4))
285 require.NoError(t, snapshot.Validate())
286 require.Len(t, snapshot.Entries, 2)
287 require.True(t, snapshot.Entries[0].Visited, "the visited route leads")
288 require.Equal(t, tt.visited.target.SessionName, snapshot.Entries[0].Name)
289 require.False(t, snapshot.Entries[1].Visited)
290 require.True(t, snapshot.Entries[1].Attention, "the ringing route follows")
291 require.Equal(t, "loud", snapshot.Entries[1].Name)
292 })
293 }
294}
295
296func TestRouteLedgerBounds(t *testing.T) {
297 sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5)
298 for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ {
299 sessions = append(sessions, routeTestLive(fmt.Sprintf("s%02d", i), byte(i+10), uint64(i)))
300 }
301 incompatible := pickerTestRemoteObservation("old@box", 3, 4, time.Unix(1000, 0))
302 incompatible.Availability = domain.RemoteAvailabilityIncompatible
303 ledger := newRouteLedger()
304 snapshot, changed := ledger.build(routeTestSnapshot(routeTestLocal(sessions...), incompatible), routeTestActive(true, "s00", 10))
305 require.True(t, changed)
306 require.NoError(t, snapshot.Validate())
307 require.Len(t, snapshot.Entries, protocol.RouteSnapshotMaxEntries)
308 require.Empty(t, snapshot.Hosts, "an incompatible daemon is never a creation host")
309}