WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import type { AtpSessionData } from '@atproto/api';
2import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3import {
4 clearCurrentSessionMetadata,
5 deleteSession,
6 getCurrentSessionMetadata,
7 loadSession,
8 saveCurrentSessionMetadata,
9 saveSession,
10} from '../../src/lib/session.js';
11import { mockSessionData, mockSessionData2, mockSessionMetadata } from '../helpers/mock-data.js';
12
13// Mock @napi-rs/keyring
14const mockKeyringStorage = new Map<string, string>();
15
16vi.mock('@napi-rs/keyring', () => {
17 return {
18 AsyncEntry: vi.fn().mockImplementation((service: string, account: string) => {
19 const key = `${service}:${account}`;
20
21 return {
22 setPassword: vi.fn().mockImplementation(async (password: string) => {
23 mockKeyringStorage.set(key, password);
24 }),
25 getPassword: vi.fn().mockImplementation(async () => {
26 return mockKeyringStorage.get(key) || null;
27 }),
28 deleteCredential: vi.fn().mockImplementation(async () => {
29 return mockKeyringStorage.delete(key);
30 }),
31 };
32 }),
33 };
34});
35
36// Mock node:fs/promises for metadata file storage
37const mockFileStorage = new Map<string, string>();
38
39vi.mock('node:fs/promises', () => ({
40 mkdir: vi.fn().mockResolvedValue(undefined),
41 writeFile: vi.fn().mockImplementation(async (path: string, content: string) => {
42 mockFileStorage.set(path as string, content);
43 }),
44 readFile: vi.fn().mockImplementation(async (path: string) => {
45 const content = mockFileStorage.get(path as string);
46 if (content === undefined) {
47 const err = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), {
48 code: 'ENOENT',
49 });
50 throw err;
51 }
52 return content;
53 }),
54 unlink: vi.fn().mockImplementation(async (path: string) => {
55 if (!mockFileStorage.has(path as string)) {
56 const err = Object.assign(new Error(`ENOENT: no such file or directory, unlink '${path}'`), {
57 code: 'ENOENT',
58 });
59 throw err;
60 }
61 mockFileStorage.delete(path as string);
62 }),
63}));
64
65describe('Session Management', () => {
66 beforeEach(() => {
67 // Clear mock storage before each test
68 mockKeyringStorage.clear();
69 mockFileStorage.clear();
70 vi.clearAllMocks();
71 });
72
73 afterEach(() => {
74 // Clean up after each test
75 mockKeyringStorage.clear();
76 mockFileStorage.clear();
77 });
78
79 describe('saveSession', () => {
80 it('should save session to keychain using DID', async () => {
81 await saveSession(mockSessionData);
82
83 // Verify session was stored
84 const loaded = await loadSession(mockSessionData.did);
85 expect(loaded).toEqual(mockSessionData);
86 });
87
88 it('should save and retrieve session with all required fields', async () => {
89 await saveSession(mockSessionData2);
90
91 // Verify session was stored (keyed by DID)
92 const loaded = await loadSession(mockSessionData2.did);
93 expect(loaded).toEqual(mockSessionData2);
94 });
95
96 it('should throw error if session has no DID or handle', async () => {
97 const sessionData = {
98 email: 'user@example.com',
99 accessJwt: 'token123',
100 refreshJwt: 'refresh123',
101 } as AtpSessionData;
102
103 await expect(saveSession(sessionData)).rejects.toThrow(
104 'Session data must include DID or handle'
105 );
106 });
107 });
108
109 describe('loadSession', () => {
110 it('should load session from keychain', async () => {
111 await saveSession(mockSessionData);
112 const result = await loadSession(mockSessionData.did);
113
114 expect(result).toEqual(mockSessionData);
115 });
116
117 it('should return null when session not found', async () => {
118 const result = await loadSession('did:plc:notfound');
119 expect(result).toBeNull();
120 });
121 });
122
123 describe('deleteSession', () => {
124 it('should delete session from keychain', async () => {
125 await saveSession(mockSessionData);
126
127 // Verify session exists
128 let loaded = await loadSession(mockSessionData.did);
129 expect(loaded).toEqual(mockSessionData);
130
131 // Delete session
132 const deleted = await deleteSession(mockSessionData.did);
133 expect(deleted).toBe(true);
134
135 // Verify session no longer exists
136 loaded = await loadSession(mockSessionData.did);
137 expect(loaded).toBeNull();
138 });
139
140 it('should return false when deleting non-existent session', async () => {
141 const deleted = await deleteSession('did:plc:notfound');
142 expect(deleted).toBe(false);
143 });
144 });
145
146 describe('session metadata', () => {
147 it('should save and load current session metadata', async () => {
148 await saveCurrentSessionMetadata(mockSessionMetadata);
149 const result = await getCurrentSessionMetadata();
150
151 expect(result).toEqual(mockSessionMetadata);
152 });
153
154 it('should return null when no metadata exists', async () => {
155 const result = await getCurrentSessionMetadata();
156 expect(result).toBeNull();
157 });
158
159 it('should clear current session metadata', async () => {
160 await saveCurrentSessionMetadata(mockSessionMetadata);
161
162 // Verify metadata exists
163 let result = await getCurrentSessionMetadata();
164 expect(result).toEqual(mockSessionMetadata);
165
166 // Clear metadata
167 await clearCurrentSessionMetadata();
168
169 // Verify metadata no longer exists
170 result = await getCurrentSessionMetadata();
171 expect(result).toBeNull();
172 });
173 });
174});