mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {BskyAgent} from '@atproto/api'
2import {describe, expect, it, jest} from '@jest/globals'
3
4import {agentToSessionAccountOrThrow} from '../agent'
5import {Action, getInitialState, reducer, State} from '../reducer'
6
7jest.mock('jwt-decode', () => ({
8 jwtDecode(_token: string) {
9 return {}
10 },
11}))
12
13describe('session', () => {
14 it('can log in and out', () => {
15 let state = getInitialState([])
16 expect(printState(state)).toMatchInlineSnapshot(`
17 {
18 "accounts": [],
19 "currentAgentState": {
20 "agent": {
21 "service": "https://public.api.bsky.app/",
22 },
23 "did": undefined,
24 },
25 "needsPersist": false,
26 }
27 `)
28
29 const agent = new BskyAgent({service: 'https://alice.com'})
30 agent.session = {
31 did: 'alice-did',
32 handle: 'alice.test',
33 accessJwt: 'alice-access-jwt-1',
34 refreshJwt: 'alice-refresh-jwt-1',
35 }
36 state = run(state, [
37 {
38 type: 'switched-to-account',
39 newAgent: agent,
40 newAccount: agentToSessionAccountOrThrow(agent),
41 },
42 ])
43 expect(state.currentAgentState.did).toBe('alice-did')
44 expect(state.accounts.length).toBe(1)
45 expect(state.accounts[0].did).toBe('alice-did')
46 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
47 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
48 expect(printState(state)).toMatchInlineSnapshot(`
49 {
50 "accounts": [
51 {
52 "accessJwt": "alice-access-jwt-1",
53 "did": "alice-did",
54 "email": undefined,
55 "emailAuthFactor": false,
56 "emailConfirmed": false,
57 "handle": "alice.test",
58 "pdsUrl": undefined,
59 "refreshJwt": "alice-refresh-jwt-1",
60 "service": "https://alice.com/",
61 "signupQueued": false,
62 "status": undefined,
63 },
64 ],
65 "currentAgentState": {
66 "agent": {
67 "service": "https://alice.com/",
68 },
69 "did": "alice-did",
70 },
71 "needsPersist": true,
72 }
73 `)
74
75 state = run(state, [
76 {
77 type: 'logged-out',
78 },
79 ])
80 // Should keep the account but clear out the tokens.
81 expect(state.currentAgentState.did).toBe(undefined)
82 expect(state.accounts.length).toBe(1)
83 expect(state.accounts[0].did).toBe('alice-did')
84 expect(state.accounts[0].accessJwt).toBe(undefined)
85 expect(state.accounts[0].refreshJwt).toBe(undefined)
86 expect(printState(state)).toMatchInlineSnapshot(`
87 {
88 "accounts": [
89 {
90 "accessJwt": undefined,
91 "did": "alice-did",
92 "email": undefined,
93 "emailAuthFactor": false,
94 "emailConfirmed": false,
95 "handle": "alice.test",
96 "pdsUrl": undefined,
97 "refreshJwt": undefined,
98 "service": "https://alice.com/",
99 "signupQueued": false,
100 "status": undefined,
101 },
102 ],
103 "currentAgentState": {
104 "agent": {
105 "service": "https://public.api.bsky.app/",
106 },
107 "did": undefined,
108 },
109 "needsPersist": true,
110 }
111 `)
112 })
113
114 it('switches to the latest account, stores all of them', () => {
115 let state = getInitialState([])
116
117 const agent1 = new BskyAgent({service: 'https://alice.com'})
118 agent1.session = {
119 did: 'alice-did',
120 handle: 'alice.test',
121 accessJwt: 'alice-access-jwt-1',
122 refreshJwt: 'alice-refresh-jwt-1',
123 }
124 state = run(state, [
125 {
126 // Switch to Alice.
127 type: 'switched-to-account',
128 newAgent: agent1,
129 newAccount: agentToSessionAccountOrThrow(agent1),
130 },
131 ])
132 expect(state.accounts.length).toBe(1)
133 expect(state.accounts[0].did).toBe('alice-did')
134 expect(state.currentAgentState.did).toBe('alice-did')
135 expect(state.currentAgentState.agent).toBe(agent1)
136 expect(printState(state)).toMatchInlineSnapshot(`
137 {
138 "accounts": [
139 {
140 "accessJwt": "alice-access-jwt-1",
141 "did": "alice-did",
142 "email": undefined,
143 "emailAuthFactor": false,
144 "emailConfirmed": false,
145 "handle": "alice.test",
146 "pdsUrl": undefined,
147 "refreshJwt": "alice-refresh-jwt-1",
148 "service": "https://alice.com/",
149 "signupQueued": false,
150 "status": undefined,
151 },
152 ],
153 "currentAgentState": {
154 "agent": {
155 "service": "https://alice.com/",
156 },
157 "did": "alice-did",
158 },
159 "needsPersist": true,
160 }
161 `)
162
163 const agent2 = new BskyAgent({service: 'https://bob.com'})
164 agent2.session = {
165 did: 'bob-did',
166 handle: 'bob.test',
167 accessJwt: 'bob-access-jwt-1',
168 refreshJwt: 'bob-refresh-jwt-1',
169 }
170 state = run(state, [
171 {
172 // Switch to Bob.
173 type: 'switched-to-account',
174 newAgent: agent2,
175 newAccount: agentToSessionAccountOrThrow(agent2),
176 },
177 ])
178 expect(state.accounts.length).toBe(2)
179 // Bob should float upwards.
180 expect(state.accounts[0].did).toBe('bob-did')
181 expect(state.accounts[1].did).toBe('alice-did')
182 expect(state.currentAgentState.did).toBe('bob-did')
183 expect(state.currentAgentState.agent).toBe(agent2)
184 expect(printState(state)).toMatchInlineSnapshot(`
185 {
186 "accounts": [
187 {
188 "accessJwt": "bob-access-jwt-1",
189 "did": "bob-did",
190 "email": undefined,
191 "emailAuthFactor": false,
192 "emailConfirmed": false,
193 "handle": "bob.test",
194 "pdsUrl": undefined,
195 "refreshJwt": "bob-refresh-jwt-1",
196 "service": "https://bob.com/",
197 "signupQueued": false,
198 "status": undefined,
199 },
200 {
201 "accessJwt": "alice-access-jwt-1",
202 "did": "alice-did",
203 "email": undefined,
204 "emailAuthFactor": false,
205 "emailConfirmed": false,
206 "handle": "alice.test",
207 "pdsUrl": undefined,
208 "refreshJwt": "alice-refresh-jwt-1",
209 "service": "https://alice.com/",
210 "signupQueued": false,
211 "status": undefined,
212 },
213 ],
214 "currentAgentState": {
215 "agent": {
216 "service": "https://bob.com/",
217 },
218 "did": "bob-did",
219 },
220 "needsPersist": true,
221 }
222 `)
223
224 const agent3 = new BskyAgent({service: 'https://alice.com'})
225 agent3.session = {
226 did: 'alice-did',
227 handle: 'alice-updated.test',
228 accessJwt: 'alice-access-jwt-2',
229 refreshJwt: 'alice-refresh-jwt-2',
230 }
231 state = run(state, [
232 {
233 // Switch back to Alice.
234 type: 'switched-to-account',
235 newAgent: agent3,
236 newAccount: agentToSessionAccountOrThrow(agent3),
237 },
238 ])
239 expect(state.accounts.length).toBe(2)
240 // Alice should float upwards.
241 expect(state.accounts[0].did).toBe('alice-did')
242 expect(state.accounts[0].handle).toBe('alice-updated.test')
243 expect(state.currentAgentState.did).toBe('alice-did')
244 expect(state.currentAgentState.agent).toBe(agent3)
245 expect(printState(state)).toMatchInlineSnapshot(`
246 {
247 "accounts": [
248 {
249 "accessJwt": "alice-access-jwt-2",
250 "did": "alice-did",
251 "email": undefined,
252 "emailAuthFactor": false,
253 "emailConfirmed": false,
254 "handle": "alice-updated.test",
255 "pdsUrl": undefined,
256 "refreshJwt": "alice-refresh-jwt-2",
257 "service": "https://alice.com/",
258 "signupQueued": false,
259 "status": undefined,
260 },
261 {
262 "accessJwt": "bob-access-jwt-1",
263 "did": "bob-did",
264 "email": undefined,
265 "emailAuthFactor": false,
266 "emailConfirmed": false,
267 "handle": "bob.test",
268 "pdsUrl": undefined,
269 "refreshJwt": "bob-refresh-jwt-1",
270 "service": "https://bob.com/",
271 "signupQueued": false,
272 "status": undefined,
273 },
274 ],
275 "currentAgentState": {
276 "agent": {
277 "service": "https://alice.com/",
278 },
279 "did": "alice-did",
280 },
281 "needsPersist": true,
282 }
283 `)
284
285 const agent4 = new BskyAgent({service: 'https://jay.com'})
286 agent4.session = {
287 did: 'jay-did',
288 handle: 'jay.test',
289 accessJwt: 'jay-access-jwt-1',
290 refreshJwt: 'jay-refresh-jwt-1',
291 }
292 state = run(state, [
293 {
294 // Switch to Jay.
295 type: 'switched-to-account',
296 newAgent: agent4,
297 newAccount: agentToSessionAccountOrThrow(agent4),
298 },
299 ])
300 expect(state.accounts.length).toBe(3)
301 expect(state.accounts[0].did).toBe('jay-did')
302 expect(state.currentAgentState.did).toBe('jay-did')
303 expect(state.currentAgentState.agent).toBe(agent4)
304 expect(printState(state)).toMatchInlineSnapshot(`
305 {
306 "accounts": [
307 {
308 "accessJwt": "jay-access-jwt-1",
309 "did": "jay-did",
310 "email": undefined,
311 "emailAuthFactor": false,
312 "emailConfirmed": false,
313 "handle": "jay.test",
314 "pdsUrl": undefined,
315 "refreshJwt": "jay-refresh-jwt-1",
316 "service": "https://jay.com/",
317 "signupQueued": false,
318 "status": undefined,
319 },
320 {
321 "accessJwt": "alice-access-jwt-2",
322 "did": "alice-did",
323 "email": undefined,
324 "emailAuthFactor": false,
325 "emailConfirmed": false,
326 "handle": "alice-updated.test",
327 "pdsUrl": undefined,
328 "refreshJwt": "alice-refresh-jwt-2",
329 "service": "https://alice.com/",
330 "signupQueued": false,
331 "status": undefined,
332 },
333 {
334 "accessJwt": "bob-access-jwt-1",
335 "did": "bob-did",
336 "email": undefined,
337 "emailAuthFactor": false,
338 "emailConfirmed": false,
339 "handle": "bob.test",
340 "pdsUrl": undefined,
341 "refreshJwt": "bob-refresh-jwt-1",
342 "service": "https://bob.com/",
343 "signupQueued": false,
344 "status": undefined,
345 },
346 ],
347 "currentAgentState": {
348 "agent": {
349 "service": "https://jay.com/",
350 },
351 "did": "jay-did",
352 },
353 "needsPersist": true,
354 }
355 `)
356
357 state = run(state, [
358 {
359 // Log everyone out.
360 type: 'logged-out',
361 },
362 ])
363 expect(state.accounts.length).toBe(3)
364 expect(state.currentAgentState.did).toBe(undefined)
365 // All tokens should be gone.
366 expect(state.accounts[0].accessJwt).toBe(undefined)
367 expect(state.accounts[0].refreshJwt).toBe(undefined)
368 expect(state.accounts[1].accessJwt).toBe(undefined)
369 expect(state.accounts[1].refreshJwt).toBe(undefined)
370 expect(state.accounts[2].accessJwt).toBe(undefined)
371 expect(state.accounts[2].refreshJwt).toBe(undefined)
372 expect(printState(state)).toMatchInlineSnapshot(`
373 {
374 "accounts": [
375 {
376 "accessJwt": undefined,
377 "did": "jay-did",
378 "email": undefined,
379 "emailAuthFactor": false,
380 "emailConfirmed": false,
381 "handle": "jay.test",
382 "pdsUrl": undefined,
383 "refreshJwt": undefined,
384 "service": "https://jay.com/",
385 "signupQueued": false,
386 "status": undefined,
387 },
388 {
389 "accessJwt": undefined,
390 "did": "alice-did",
391 "email": undefined,
392 "emailAuthFactor": false,
393 "emailConfirmed": false,
394 "handle": "alice-updated.test",
395 "pdsUrl": undefined,
396 "refreshJwt": undefined,
397 "service": "https://alice.com/",
398 "signupQueued": false,
399 "status": undefined,
400 },
401 {
402 "accessJwt": undefined,
403 "did": "bob-did",
404 "email": undefined,
405 "emailAuthFactor": false,
406 "emailConfirmed": false,
407 "handle": "bob.test",
408 "pdsUrl": undefined,
409 "refreshJwt": undefined,
410 "service": "https://bob.com/",
411 "signupQueued": false,
412 "status": undefined,
413 },
414 ],
415 "currentAgentState": {
416 "agent": {
417 "service": "https://public.api.bsky.app/",
418 },
419 "did": undefined,
420 },
421 "needsPersist": true,
422 }
423 `)
424 })
425
426 it('can log back in after logging out', () => {
427 let state = getInitialState([])
428
429 const agent1 = new BskyAgent({service: 'https://alice.com'})
430 agent1.session = {
431 did: 'alice-did',
432 handle: 'alice.test',
433 accessJwt: 'alice-access-jwt-1',
434 refreshJwt: 'alice-refresh-jwt-1',
435 }
436 state = run(state, [
437 {
438 type: 'switched-to-account',
439 newAgent: agent1,
440 newAccount: agentToSessionAccountOrThrow(agent1),
441 },
442 ])
443 expect(state.accounts.length).toBe(1)
444 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
445 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
446 expect(state.currentAgentState.did).toBe('alice-did')
447
448 state = run(state, [
449 {
450 type: 'logged-out',
451 },
452 ])
453 expect(state.accounts.length).toBe(1)
454 expect(state.accounts[0].accessJwt).toBe(undefined)
455 expect(state.accounts[0].refreshJwt).toBe(undefined)
456 expect(state.currentAgentState.did).toBe(undefined)
457 expect(printState(state)).toMatchInlineSnapshot(`
458 {
459 "accounts": [
460 {
461 "accessJwt": undefined,
462 "did": "alice-did",
463 "email": undefined,
464 "emailAuthFactor": false,
465 "emailConfirmed": false,
466 "handle": "alice.test",
467 "pdsUrl": undefined,
468 "refreshJwt": undefined,
469 "service": "https://alice.com/",
470 "signupQueued": false,
471 "status": undefined,
472 },
473 ],
474 "currentAgentState": {
475 "agent": {
476 "service": "https://public.api.bsky.app/",
477 },
478 "did": undefined,
479 },
480 "needsPersist": true,
481 }
482 `)
483
484 const agent2 = new BskyAgent({service: 'https://alice.com'})
485 agent2.session = {
486 did: 'alice-did',
487 handle: 'alice.test',
488 accessJwt: 'alice-access-jwt-2',
489 refreshJwt: 'alice-refresh-jwt-2',
490 }
491 state = run(state, [
492 {
493 type: 'switched-to-account',
494 newAgent: agent2,
495 newAccount: agentToSessionAccountOrThrow(agent2),
496 },
497 ])
498 expect(state.accounts.length).toBe(1)
499 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2')
500 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2')
501 expect(state.currentAgentState.did).toBe('alice-did')
502 expect(printState(state)).toMatchInlineSnapshot(`
503 {
504 "accounts": [
505 {
506 "accessJwt": "alice-access-jwt-2",
507 "did": "alice-did",
508 "email": undefined,
509 "emailAuthFactor": false,
510 "emailConfirmed": false,
511 "handle": "alice.test",
512 "pdsUrl": undefined,
513 "refreshJwt": "alice-refresh-jwt-2",
514 "service": "https://alice.com/",
515 "signupQueued": false,
516 "status": undefined,
517 },
518 ],
519 "currentAgentState": {
520 "agent": {
521 "service": "https://alice.com/",
522 },
523 "did": "alice-did",
524 },
525 "needsPersist": true,
526 }
527 `)
528 })
529
530 it('can remove active account', () => {
531 let state = getInitialState([])
532
533 const agent1 = new BskyAgent({service: 'https://alice.com'})
534 agent1.session = {
535 did: 'alice-did',
536 handle: 'alice.test',
537 accessJwt: 'alice-access-jwt-1',
538 refreshJwt: 'alice-refresh-jwt-1',
539 }
540 state = run(state, [
541 {
542 type: 'switched-to-account',
543 newAgent: agent1,
544 newAccount: agentToSessionAccountOrThrow(agent1),
545 },
546 ])
547 expect(state.accounts.length).toBe(1)
548 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
549 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
550 expect(state.currentAgentState.did).toBe('alice-did')
551
552 state = run(state, [
553 {
554 type: 'removed-account',
555 accountDid: 'alice-did',
556 },
557 ])
558 expect(state.accounts.length).toBe(0)
559 expect(state.currentAgentState.did).toBe(undefined)
560 expect(printState(state)).toMatchInlineSnapshot(`
561 {
562 "accounts": [],
563 "currentAgentState": {
564 "agent": {
565 "service": "https://public.api.bsky.app/",
566 },
567 "did": undefined,
568 },
569 "needsPersist": true,
570 }
571 `)
572 })
573
574 it('can remove inactive account', () => {
575 let state = getInitialState([])
576
577 const agent1 = new BskyAgent({service: 'https://alice.com'})
578 agent1.session = {
579 did: 'alice-did',
580 handle: 'alice.test',
581 accessJwt: 'alice-access-jwt-1',
582 refreshJwt: 'alice-refresh-jwt-1',
583 }
584 const agent2 = new BskyAgent({service: 'https://bob.com'})
585 agent2.session = {
586 did: 'bob-did',
587 handle: 'bob.test',
588 accessJwt: 'bob-access-jwt-1',
589 refreshJwt: 'bob-refresh-jwt-1',
590 }
591 state = run(state, [
592 {
593 type: 'switched-to-account',
594 newAgent: agent1,
595 newAccount: agentToSessionAccountOrThrow(agent1),
596 },
597 {
598 type: 'switched-to-account',
599 newAgent: agent2,
600 newAccount: agentToSessionAccountOrThrow(agent2),
601 },
602 ])
603 expect(state.accounts.length).toBe(2)
604 expect(state.currentAgentState.did).toBe('bob-did')
605
606 state = run(state, [
607 {
608 type: 'removed-account',
609 accountDid: 'alice-did',
610 },
611 ])
612 expect(state.accounts.length).toBe(1)
613 expect(state.currentAgentState.did).toBe('bob-did')
614 expect(printState(state)).toMatchInlineSnapshot(`
615 {
616 "accounts": [
617 {
618 "accessJwt": "bob-access-jwt-1",
619 "did": "bob-did",
620 "email": undefined,
621 "emailAuthFactor": false,
622 "emailConfirmed": false,
623 "handle": "bob.test",
624 "pdsUrl": undefined,
625 "refreshJwt": "bob-refresh-jwt-1",
626 "service": "https://bob.com/",
627 "signupQueued": false,
628 "status": undefined,
629 },
630 ],
631 "currentAgentState": {
632 "agent": {
633 "service": "https://bob.com/",
634 },
635 "did": "bob-did",
636 },
637 "needsPersist": true,
638 }
639 `)
640
641 state = run(state, [
642 {
643 type: 'removed-account',
644 accountDid: 'bob-did',
645 },
646 ])
647 expect(state.accounts.length).toBe(0)
648 expect(state.currentAgentState.did).toBe(undefined)
649 })
650
651 it('updates stored account with refreshed tokens', () => {
652 let state = getInitialState([])
653
654 const agent1 = new BskyAgent({service: 'https://alice.com'})
655 agent1.session = {
656 did: 'alice-did',
657 handle: 'alice.test',
658 accessJwt: 'alice-access-jwt-1',
659 refreshJwt: 'alice-refresh-jwt-1',
660 }
661 state = run(state, [
662 {
663 type: 'switched-to-account',
664 newAgent: agent1,
665 newAccount: agentToSessionAccountOrThrow(agent1),
666 },
667 ])
668 expect(state.accounts.length).toBe(1)
669 expect(state.currentAgentState.did).toBe('alice-did')
670
671 agent1.session = {
672 did: 'alice-did',
673 handle: 'alice-updated.test',
674 accessJwt: 'alice-access-jwt-2',
675 refreshJwt: 'alice-refresh-jwt-2',
676 email: 'alice@foo.bar',
677 emailAuthFactor: false,
678 emailConfirmed: false,
679 }
680 state = run(state, [
681 {
682 type: 'received-agent-event',
683 accountDid: 'alice-did',
684 agent: agent1,
685 refreshedAccount: agentToSessionAccountOrThrow(agent1),
686 sessionEvent: 'update',
687 },
688 ])
689 expect(state.accounts.length).toBe(1)
690 expect(state.accounts[0].email).toBe('alice@foo.bar')
691 expect(state.accounts[0].handle).toBe('alice-updated.test')
692 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2')
693 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2')
694 expect(state.currentAgentState.did).toBe('alice-did')
695 expect(printState(state)).toMatchInlineSnapshot(`
696 {
697 "accounts": [
698 {
699 "accessJwt": "alice-access-jwt-2",
700 "did": "alice-did",
701 "email": "alice@foo.bar",
702 "emailAuthFactor": false,
703 "emailConfirmed": false,
704 "handle": "alice-updated.test",
705 "pdsUrl": undefined,
706 "refreshJwt": "alice-refresh-jwt-2",
707 "service": "https://alice.com/",
708 "signupQueued": false,
709 "status": undefined,
710 },
711 ],
712 "currentAgentState": {
713 "agent": {
714 "service": "https://alice.com/",
715 },
716 "did": "alice-did",
717 },
718 "needsPersist": true,
719 }
720 `)
721
722 agent1.session = {
723 did: 'alice-did',
724 handle: 'alice-updated.test',
725 accessJwt: 'alice-access-jwt-3',
726 refreshJwt: 'alice-refresh-jwt-3',
727 email: 'alice@foo.baz',
728 emailAuthFactor: true,
729 emailConfirmed: true,
730 }
731 state = run(state, [
732 {
733 type: 'received-agent-event',
734 accountDid: 'alice-did',
735 agent: agent1,
736 refreshedAccount: agentToSessionAccountOrThrow(agent1),
737 sessionEvent: 'update',
738 },
739 ])
740 expect(state.accounts.length).toBe(1)
741 expect(state.accounts[0].email).toBe('alice@foo.baz')
742 expect(state.accounts[0].handle).toBe('alice-updated.test')
743 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3')
744 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-3')
745 expect(state.currentAgentState.did).toBe('alice-did')
746 expect(printState(state)).toMatchInlineSnapshot(`
747 {
748 "accounts": [
749 {
750 "accessJwt": "alice-access-jwt-3",
751 "did": "alice-did",
752 "email": "alice@foo.baz",
753 "emailAuthFactor": true,
754 "emailConfirmed": true,
755 "handle": "alice-updated.test",
756 "pdsUrl": undefined,
757 "refreshJwt": "alice-refresh-jwt-3",
758 "service": "https://alice.com/",
759 "signupQueued": false,
760 "status": undefined,
761 },
762 ],
763 "currentAgentState": {
764 "agent": {
765 "service": "https://alice.com/",
766 },
767 "did": "alice-did",
768 },
769 "needsPersist": true,
770 }
771 `)
772
773 agent1.session = {
774 did: 'alice-did',
775 handle: 'alice-updated.test',
776 accessJwt: 'alice-access-jwt-4',
777 refreshJwt: 'alice-refresh-jwt-4',
778 email: 'alice@foo.baz',
779 emailAuthFactor: false,
780 emailConfirmed: false,
781 }
782 state = run(state, [
783 {
784 type: 'received-agent-event',
785 accountDid: 'alice-did',
786 agent: agent1,
787 refreshedAccount: agentToSessionAccountOrThrow(agent1),
788 sessionEvent: 'update',
789 },
790 ])
791 expect(state.accounts.length).toBe(1)
792 expect(state.accounts[0].email).toBe('alice@foo.baz')
793 expect(state.accounts[0].handle).toBe('alice-updated.test')
794 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-4')
795 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-4')
796 expect(state.currentAgentState.did).toBe('alice-did')
797 expect(printState(state)).toMatchInlineSnapshot(`
798 {
799 "accounts": [
800 {
801 "accessJwt": "alice-access-jwt-4",
802 "did": "alice-did",
803 "email": "alice@foo.baz",
804 "emailAuthFactor": false,
805 "emailConfirmed": false,
806 "handle": "alice-updated.test",
807 "pdsUrl": undefined,
808 "refreshJwt": "alice-refresh-jwt-4",
809 "service": "https://alice.com/",
810 "signupQueued": false,
811 "status": undefined,
812 },
813 ],
814 "currentAgentState": {
815 "agent": {
816 "service": "https://alice.com/",
817 },
818 "did": "alice-did",
819 },
820 "needsPersist": true,
821 }
822 `)
823 })
824
825 it('bails out of update on identical objects', () => {
826 let state = getInitialState([])
827
828 const agent1 = new BskyAgent({service: 'https://alice.com'})
829 agent1.session = {
830 did: 'alice-did',
831 handle: 'alice.test',
832 accessJwt: 'alice-access-jwt-1',
833 refreshJwt: 'alice-refresh-jwt-1',
834 }
835 state = run(state, [
836 {
837 type: 'switched-to-account',
838 newAgent: agent1,
839 newAccount: agentToSessionAccountOrThrow(agent1),
840 },
841 ])
842 expect(state.accounts.length).toBe(1)
843 expect(state.currentAgentState.did).toBe('alice-did')
844
845 agent1.session = {
846 did: 'alice-did',
847 handle: 'alice-updated.test',
848 accessJwt: 'alice-access-jwt-2',
849 refreshJwt: 'alice-refresh-jwt-2',
850 }
851 state = run(state, [
852 {
853 type: 'received-agent-event',
854 accountDid: 'alice-did',
855 agent: agent1,
856 refreshedAccount: agentToSessionAccountOrThrow(agent1),
857 sessionEvent: 'update',
858 },
859 ])
860 expect(state.accounts.length).toBe(1)
861 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2')
862
863 const lastState = state
864 state = run(state, [
865 {
866 type: 'received-agent-event',
867 accountDid: 'alice-did',
868 agent: agent1,
869 refreshedAccount: agentToSessionAccountOrThrow(agent1),
870 sessionEvent: 'update',
871 },
872 ])
873 expect(lastState === state).toBe(true)
874
875 agent1.session = {
876 did: 'alice-did',
877 handle: 'alice-updated.test',
878 accessJwt: 'alice-access-jwt-3',
879 refreshJwt: 'alice-refresh-jwt-3',
880 }
881 state = run(state, [
882 {
883 type: 'received-agent-event',
884 accountDid: 'alice-did',
885 agent: agent1,
886 refreshedAccount: agentToSessionAccountOrThrow(agent1),
887 sessionEvent: 'update',
888 },
889 ])
890 expect(state.accounts.length).toBe(1)
891 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3')
892 })
893
894 it('accepts updates from a stale agent', () => {
895 let state = getInitialState([])
896
897 const agent1 = new BskyAgent({service: 'https://alice.com'})
898 agent1.session = {
899 did: 'alice-did',
900 handle: 'alice.test',
901 accessJwt: 'alice-access-jwt-1',
902 refreshJwt: 'alice-refresh-jwt-1',
903 }
904
905 const agent2 = new BskyAgent({service: 'https://bob.com'})
906 agent2.session = {
907 did: 'bob-did',
908 handle: 'bob.test',
909 accessJwt: 'bob-access-jwt-1',
910 refreshJwt: 'bob-refresh-jwt-1',
911 }
912
913 state = run(state, [
914 {
915 // Switch to Alice.
916 type: 'switched-to-account',
917 newAgent: agent1,
918 newAccount: agentToSessionAccountOrThrow(agent1),
919 },
920 {
921 // Switch to Bob.
922 type: 'switched-to-account',
923 newAgent: agent2,
924 newAccount: agentToSessionAccountOrThrow(agent2),
925 },
926 ])
927 expect(state.accounts.length).toBe(2)
928 expect(state.currentAgentState.did).toBe('bob-did')
929
930 agent1.session = {
931 did: 'alice-did',
932 handle: 'alice-updated.test',
933 accessJwt: 'alice-access-jwt-2',
934 refreshJwt: 'alice-refresh-jwt-2',
935 email: 'alice@foo.bar',
936 emailAuthFactor: false,
937 emailConfirmed: false,
938 }
939 state = run(state, [
940 {
941 type: 'received-agent-event',
942 accountDid: 'alice-did',
943 agent: agent1,
944 refreshedAccount: agentToSessionAccountOrThrow(agent1),
945 sessionEvent: 'update',
946 },
947 ])
948 expect(state.accounts.length).toBe(2)
949 expect(state.accounts[1].did).toBe('alice-did')
950 // Should update Alice's tokens because otherwise they'll be stale.
951 expect(state.accounts[1].handle).toBe('alice-updated.test')
952 expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-2')
953 expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-2')
954 expect(printState(state)).toMatchInlineSnapshot(`
955 {
956 "accounts": [
957 {
958 "accessJwt": "bob-access-jwt-1",
959 "did": "bob-did",
960 "email": undefined,
961 "emailAuthFactor": false,
962 "emailConfirmed": false,
963 "handle": "bob.test",
964 "pdsUrl": undefined,
965 "refreshJwt": "bob-refresh-jwt-1",
966 "service": "https://bob.com/",
967 "signupQueued": false,
968 "status": undefined,
969 },
970 {
971 "accessJwt": "alice-access-jwt-2",
972 "did": "alice-did",
973 "email": "alice@foo.bar",
974 "emailAuthFactor": false,
975 "emailConfirmed": false,
976 "handle": "alice-updated.test",
977 "pdsUrl": undefined,
978 "refreshJwt": "alice-refresh-jwt-2",
979 "service": "https://alice.com/",
980 "signupQueued": false,
981 "status": undefined,
982 },
983 ],
984 "currentAgentState": {
985 "agent": {
986 "service": "https://bob.com/",
987 },
988 "did": "bob-did",
989 },
990 "needsPersist": true,
991 }
992 `)
993
994 agent2.session = {
995 did: 'bob-did',
996 handle: 'bob-updated.test',
997 accessJwt: 'bob-access-jwt-2',
998 refreshJwt: 'bob-refresh-jwt-2',
999 }
1000 state = run(state, [
1001 {
1002 // Update Bob.
1003 type: 'received-agent-event',
1004 accountDid: 'bob-did',
1005 agent: agent2,
1006 refreshedAccount: agentToSessionAccountOrThrow(agent2),
1007 sessionEvent: 'update',
1008 },
1009 ])
1010 expect(state.accounts.length).toBe(2)
1011 expect(state.accounts[0].did).toBe('bob-did')
1012 // Should update Bob's tokens because otherwise they'll be stale.
1013 expect(state.accounts[0].handle).toBe('bob-updated.test')
1014 expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-2')
1015 expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-2')
1016 expect(printState(state)).toMatchInlineSnapshot(`
1017 {
1018 "accounts": [
1019 {
1020 "accessJwt": "bob-access-jwt-2",
1021 "did": "bob-did",
1022 "email": undefined,
1023 "emailAuthFactor": false,
1024 "emailConfirmed": false,
1025 "handle": "bob-updated.test",
1026 "pdsUrl": undefined,
1027 "refreshJwt": "bob-refresh-jwt-2",
1028 "service": "https://bob.com/",
1029 "signupQueued": false,
1030 "status": undefined,
1031 },
1032 {
1033 "accessJwt": "alice-access-jwt-2",
1034 "did": "alice-did",
1035 "email": "alice@foo.bar",
1036 "emailAuthFactor": false,
1037 "emailConfirmed": false,
1038 "handle": "alice-updated.test",
1039 "pdsUrl": undefined,
1040 "refreshJwt": "alice-refresh-jwt-2",
1041 "service": "https://alice.com/",
1042 "signupQueued": false,
1043 "status": undefined,
1044 },
1045 ],
1046 "currentAgentState": {
1047 "agent": {
1048 "service": "https://bob.com/",
1049 },
1050 "did": "bob-did",
1051 },
1052 "needsPersist": true,
1053 }
1054 `)
1055
1056 // Ignore other events for inactive agent.
1057 const lastState = state
1058 agent1.session = undefined
1059 state = run(state, [
1060 {
1061 type: 'received-agent-event',
1062 accountDid: 'alice-did',
1063 agent: agent1,
1064 refreshedAccount: undefined,
1065 sessionEvent: 'network-error',
1066 },
1067 ])
1068 expect(lastState === state).toBe(true)
1069 state = run(state, [
1070 {
1071 type: 'received-agent-event',
1072 accountDid: 'alice-did',
1073 agent: agent1,
1074 refreshedAccount: undefined,
1075 sessionEvent: 'expired',
1076 },
1077 ])
1078 expect(lastState === state).toBe(true)
1079 })
1080
1081 it('ignores updates from a removed agent', () => {
1082 let state = getInitialState([])
1083
1084 const agent1 = new BskyAgent({service: 'https://alice.com'})
1085 agent1.session = {
1086 did: 'alice-did',
1087 handle: 'alice.test',
1088 accessJwt: 'alice-access-jwt-1',
1089 refreshJwt: 'alice-refresh-jwt-1',
1090 }
1091
1092 const agent2 = new BskyAgent({service: 'https://bob.com'})
1093 agent2.session = {
1094 did: 'bob-did',
1095 handle: 'bob.test',
1096 accessJwt: 'bob-access-jwt-1',
1097 refreshJwt: 'bob-refresh-jwt-1',
1098 }
1099
1100 state = run(state, [
1101 {
1102 type: 'switched-to-account',
1103 newAgent: agent1,
1104 newAccount: agentToSessionAccountOrThrow(agent1),
1105 },
1106 {
1107 type: 'switched-to-account',
1108 newAgent: agent2,
1109 newAccount: agentToSessionAccountOrThrow(agent2),
1110 },
1111 {
1112 type: 'removed-account',
1113 accountDid: 'alice-did',
1114 },
1115 ])
1116 expect(state.accounts.length).toBe(1)
1117 expect(state.currentAgentState.did).toBe('bob-did')
1118
1119 agent1.session = {
1120 did: 'alice-did',
1121 handle: 'alice.test',
1122 accessJwt: 'alice-access-jwt-2',
1123 refreshJwt: 'alice-refresh-jwt-2',
1124 }
1125 state = run(state, [
1126 {
1127 type: 'received-agent-event',
1128 accountDid: 'alice-did',
1129 agent: agent1,
1130 refreshedAccount: agentToSessionAccountOrThrow(agent1),
1131 sessionEvent: 'update',
1132 },
1133 ])
1134 expect(state.accounts.length).toBe(1)
1135 expect(state.accounts[0].did).toBe('bob-did')
1136 expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1')
1137 expect(state.currentAgentState.did).toBe('bob-did')
1138 })
1139
1140 it('does soft logout on network error', () => {
1141 let state = getInitialState([])
1142
1143 const agent1 = new BskyAgent({service: 'https://alice.com'})
1144 agent1.session = {
1145 did: 'alice-did',
1146 handle: 'alice.test',
1147 accessJwt: 'alice-access-jwt-1',
1148 refreshJwt: 'alice-refresh-jwt-1',
1149 }
1150 state = run(state, [
1151 {
1152 // Switch to Alice.
1153 type: 'switched-to-account',
1154 newAgent: agent1,
1155 newAccount: agentToSessionAccountOrThrow(agent1),
1156 },
1157 ])
1158 expect(state.accounts.length).toBe(1)
1159 expect(state.currentAgentState.did).toBe('alice-did')
1160
1161 agent1.session = undefined
1162 state = run(state, [
1163 {
1164 type: 'received-agent-event',
1165 accountDid: 'alice-did',
1166 agent: agent1,
1167 refreshedAccount: undefined,
1168 sessionEvent: 'network-error',
1169 },
1170 ])
1171 expect(state.accounts.length).toBe(1)
1172 // Network error should reset current user but not reset the tokens.
1173 // TODO: We might want to remove or change this behavior?
1174 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
1175 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
1176 expect(state.currentAgentState.did).toBe(undefined)
1177 expect(printState(state)).toMatchInlineSnapshot(`
1178 {
1179 "accounts": [
1180 {
1181 "accessJwt": "alice-access-jwt-1",
1182 "did": "alice-did",
1183 "email": undefined,
1184 "emailAuthFactor": false,
1185 "emailConfirmed": false,
1186 "handle": "alice.test",
1187 "pdsUrl": undefined,
1188 "refreshJwt": "alice-refresh-jwt-1",
1189 "service": "https://alice.com/",
1190 "signupQueued": false,
1191 "status": undefined,
1192 },
1193 ],
1194 "currentAgentState": {
1195 "agent": {
1196 "service": "https://public.api.bsky.app/",
1197 },
1198 "did": undefined,
1199 },
1200 "needsPersist": true,
1201 }
1202 `)
1203 })
1204
1205 it('resets tokens on expired event', () => {
1206 let state = getInitialState([])
1207
1208 const agent1 = new BskyAgent({service: 'https://alice.com'})
1209 agent1.session = {
1210 did: 'alice-did',
1211 handle: 'alice.test',
1212 accessJwt: 'alice-access-jwt-1',
1213 refreshJwt: 'alice-refresh-jwt-1',
1214 }
1215 state = run(state, [
1216 {
1217 type: 'switched-to-account',
1218 newAgent: agent1,
1219 newAccount: agentToSessionAccountOrThrow(agent1),
1220 },
1221 ])
1222 expect(state.accounts.length).toBe(1)
1223 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
1224 expect(state.currentAgentState.did).toBe('alice-did')
1225
1226 agent1.session = undefined
1227 state = run(state, [
1228 {
1229 type: 'received-agent-event',
1230 accountDid: 'alice-did',
1231 agent: agent1,
1232 refreshedAccount: undefined,
1233 sessionEvent: 'expired',
1234 },
1235 ])
1236 expect(state.accounts.length).toBe(1)
1237 expect(state.accounts[0].accessJwt).toBe(undefined)
1238 expect(state.accounts[0].refreshJwt).toBe(undefined)
1239 expect(state.currentAgentState.did).toBe(undefined)
1240 expect(printState(state)).toMatchInlineSnapshot(`
1241 {
1242 "accounts": [
1243 {
1244 "accessJwt": undefined,
1245 "did": "alice-did",
1246 "email": undefined,
1247 "emailAuthFactor": false,
1248 "emailConfirmed": false,
1249 "handle": "alice.test",
1250 "pdsUrl": undefined,
1251 "refreshJwt": undefined,
1252 "service": "https://alice.com/",
1253 "signupQueued": false,
1254 "status": undefined,
1255 },
1256 ],
1257 "currentAgentState": {
1258 "agent": {
1259 "service": "https://public.api.bsky.app/",
1260 },
1261 "did": undefined,
1262 },
1263 "needsPersist": true,
1264 }
1265 `)
1266 })
1267
1268 it('resets tokens on created-failed event', () => {
1269 let state = getInitialState([])
1270
1271 const agent1 = new BskyAgent({service: 'https://alice.com'})
1272 agent1.session = {
1273 did: 'alice-did',
1274 handle: 'alice.test',
1275 accessJwt: 'alice-access-jwt-1',
1276 refreshJwt: 'alice-refresh-jwt-1',
1277 }
1278 state = run(state, [
1279 {
1280 type: 'switched-to-account',
1281 newAgent: agent1,
1282 newAccount: agentToSessionAccountOrThrow(agent1),
1283 },
1284 ])
1285 expect(state.accounts.length).toBe(1)
1286 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
1287 expect(state.currentAgentState.did).toBe('alice-did')
1288
1289 agent1.session = undefined
1290 state = run(state, [
1291 {
1292 type: 'received-agent-event',
1293 accountDid: 'alice-did',
1294 agent: agent1,
1295 refreshedAccount: undefined,
1296 sessionEvent: 'create-failed',
1297 },
1298 ])
1299 expect(state.accounts.length).toBe(1)
1300 expect(state.accounts[0].accessJwt).toBe(undefined)
1301 expect(state.accounts[0].refreshJwt).toBe(undefined)
1302 expect(state.currentAgentState.did).toBe(undefined)
1303 expect(printState(state)).toMatchInlineSnapshot(`
1304 {
1305 "accounts": [
1306 {
1307 "accessJwt": undefined,
1308 "did": "alice-did",
1309 "email": undefined,
1310 "emailAuthFactor": false,
1311 "emailConfirmed": false,
1312 "handle": "alice.test",
1313 "pdsUrl": undefined,
1314 "refreshJwt": undefined,
1315 "service": "https://alice.com/",
1316 "signupQueued": false,
1317 "status": undefined,
1318 },
1319 ],
1320 "currentAgentState": {
1321 "agent": {
1322 "service": "https://public.api.bsky.app/",
1323 },
1324 "did": undefined,
1325 },
1326 "needsPersist": true,
1327 }
1328 `)
1329 })
1330
1331 it('replaces local accounts with synced accounts', () => {
1332 let state = getInitialState([])
1333
1334 const agent1 = new BskyAgent({service: 'https://alice.com'})
1335 agent1.session = {
1336 did: 'alice-did',
1337 handle: 'alice.test',
1338 accessJwt: 'alice-access-jwt-1',
1339 refreshJwt: 'alice-refresh-jwt-1',
1340 }
1341 const agent2 = new BskyAgent({service: 'https://bob.com'})
1342 agent2.session = {
1343 did: 'bob-did',
1344 handle: 'bob.test',
1345 accessJwt: 'bob-access-jwt-1',
1346 refreshJwt: 'bob-refresh-jwt-1',
1347 }
1348 state = run(state, [
1349 {
1350 type: 'switched-to-account',
1351 newAgent: agent1,
1352 newAccount: agentToSessionAccountOrThrow(agent1),
1353 },
1354 {
1355 type: 'switched-to-account',
1356 newAgent: agent2,
1357 newAccount: agentToSessionAccountOrThrow(agent2),
1358 },
1359 ])
1360 expect(state.accounts.length).toBe(2)
1361 expect(state.currentAgentState.did).toBe('bob-did')
1362
1363 const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'})
1364 anotherTabAgent1.session = {
1365 did: 'jay-did',
1366 handle: 'jay.test',
1367 accessJwt: 'jay-access-jwt-1',
1368 refreshJwt: 'jay-refresh-jwt-1',
1369 }
1370 const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'})
1371 anotherTabAgent2.session = {
1372 did: 'bob-did',
1373 handle: 'bob.test',
1374 accessJwt: 'bob-access-jwt-2',
1375 refreshJwt: 'bob-refresh-jwt-2',
1376 }
1377 state = run(state, [
1378 {
1379 type: 'synced-accounts',
1380 syncedAccounts: [
1381 agentToSessionAccountOrThrow(anotherTabAgent1),
1382 agentToSessionAccountOrThrow(anotherTabAgent2),
1383 ],
1384 syncedCurrentDid: 'bob-did',
1385 },
1386 ])
1387 expect(state.accounts.length).toBe(2)
1388 expect(state.accounts[0].did).toBe('jay-did')
1389 expect(state.accounts[1].did).toBe('bob-did')
1390 expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2')
1391 // Keep Bob logged in.
1392 // (We patch up agent.session outside the reducer for this to work.)
1393 expect(state.currentAgentState.did).toBe('bob-did')
1394 expect(state.needsPersist).toBe(false)
1395 expect(printState(state)).toMatchInlineSnapshot(`
1396 {
1397 "accounts": [
1398 {
1399 "accessJwt": "jay-access-jwt-1",
1400 "did": "jay-did",
1401 "email": undefined,
1402 "emailAuthFactor": false,
1403 "emailConfirmed": false,
1404 "handle": "jay.test",
1405 "pdsUrl": undefined,
1406 "refreshJwt": "jay-refresh-jwt-1",
1407 "service": "https://jay.com/",
1408 "signupQueued": false,
1409 "status": undefined,
1410 },
1411 {
1412 "accessJwt": "bob-access-jwt-2",
1413 "did": "bob-did",
1414 "email": undefined,
1415 "emailAuthFactor": false,
1416 "emailConfirmed": false,
1417 "handle": "bob.test",
1418 "pdsUrl": undefined,
1419 "refreshJwt": "bob-refresh-jwt-2",
1420 "service": "https://alice.com/",
1421 "signupQueued": false,
1422 "status": undefined,
1423 },
1424 ],
1425 "currentAgentState": {
1426 "agent": {
1427 "service": "https://bob.com/",
1428 },
1429 "did": "bob-did",
1430 },
1431 "needsPersist": false,
1432 }
1433 `)
1434
1435 const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'})
1436 anotherTabAgent3.session = {
1437 did: 'clarence-did',
1438 handle: 'clarence.test',
1439 accessJwt: 'clarence-access-jwt-2',
1440 refreshJwt: 'clarence-refresh-jwt-2',
1441 }
1442 state = run(state, [
1443 {
1444 type: 'synced-accounts',
1445 syncedAccounts: [agentToSessionAccountOrThrow(anotherTabAgent3)],
1446 syncedCurrentDid: 'clarence-did',
1447 },
1448 ])
1449 expect(state.accounts.length).toBe(1)
1450 expect(state.accounts[0].did).toBe('clarence-did')
1451 // Log out because we have no matching user.
1452 // (In practice, we'll resume this session outside the reducer.)
1453 expect(state.currentAgentState.did).toBe(undefined)
1454 expect(state.needsPersist).toBe(false)
1455 expect(printState(state)).toMatchInlineSnapshot(`
1456 {
1457 "accounts": [
1458 {
1459 "accessJwt": "clarence-access-jwt-2",
1460 "did": "clarence-did",
1461 "email": undefined,
1462 "emailAuthFactor": false,
1463 "emailConfirmed": false,
1464 "handle": "clarence.test",
1465 "pdsUrl": undefined,
1466 "refreshJwt": "clarence-refresh-jwt-2",
1467 "service": "https://clarence.com/",
1468 "signupQueued": false,
1469 "status": undefined,
1470 },
1471 ],
1472 "currentAgentState": {
1473 "agent": {
1474 "service": "https://public.api.bsky.app/",
1475 },
1476 "did": undefined,
1477 },
1478 "needsPersist": false,
1479 }
1480 `)
1481 })
1482})
1483
1484function run(initialState: State, actions: Action[]): State {
1485 let state = initialState
1486 for (let action of actions) {
1487 state = reducer(state, action)
1488 }
1489 return state
1490}
1491
1492function printState(state: State) {
1493 return {
1494 accounts: state.accounts,
1495 currentAgentState: {
1496 agent: {service: state.currentAgentState.agent.service},
1497 did: state.currentAgentState.did,
1498 },
1499 needsPersist: state.needsPersist,
1500 }
1501}