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 { beforeEach, describe, expect, it, vi } from 'vitest';
3import { TangledApiClient } from '../../src/lib/api-client.js';
4import * as sessionModule from '../../src/lib/session.js';
5import { KeychainAccessError } from '../../src/lib/session.js';
6import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js';
7
8// Mock @atproto/api
9vi.mock('@atproto/api', () => {
10 return {
11 AtpAgent: vi.fn().mockImplementation(() => {
12 let currentSession: AtpSessionData | undefined;
13
14 return {
15 service: { toString: () => 'https://bsky.social' },
16 get session() {
17 return currentSession;
18 },
19 login: vi.fn().mockImplementation(async () => {
20 currentSession = mockSessionData;
21 return {
22 success: true,
23 data: mockSessionData,
24 };
25 }),
26 resumeSession: vi.fn().mockImplementation(async (session) => {
27 currentSession = session;
28 }),
29 };
30 }),
31 };
32});
33
34// Mock session management (use importOriginal to preserve KeychainAccessError class)
35vi.mock('../../src/lib/session.js', async (importOriginal) => {
36 const actual = await importOriginal<typeof import('../../src/lib/session.js')>();
37 return {
38 ...actual,
39 saveSession: vi.fn(),
40 loadSession: vi.fn(),
41 deleteSession: vi.fn(),
42 saveCurrentSessionMetadata: vi.fn(),
43 getCurrentSessionMetadata: vi.fn(),
44 clearCurrentSessionMetadata: vi.fn(),
45 };
46});
47
48describe('TangledApiClient', () => {
49 let client: TangledApiClient;
50
51 beforeEach(() => {
52 vi.clearAllMocks();
53
54 // Reset mock implementations
55 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null);
56 vi.mocked(sessionModule.loadSession).mockResolvedValue(null);
57
58 client = new TangledApiClient();
59 });
60
61 describe('login', () => {
62 it('should login successfully and save session', async () => {
63 const result = await client.login('user.bsky.social', 'password');
64
65 expect(result).toEqual(mockSessionData);
66 expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledWith(mockSessionData);
67 expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({
68 handle: mockSessionData.handle,
69 did: mockSessionData.did,
70 pds: 'https://bsky.social',
71 lastUsed: expect.any(String),
72 });
73 });
74
75 it('should support custom domain handles', async () => {
76 const result = await client.login('markbennett.ca', 'password');
77
78 expect(result).toEqual(mockSessionData);
79 expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalled();
80 });
81
82 it('should throw error on login failure', async () => {
83 const agent = client.getAgent();
84 vi.mocked(agent.login).mockResolvedValueOnce({
85 success: false,
86 headers: {},
87 data: undefined,
88 } as never);
89
90 await expect(client.login('user.bsky.social', 'wrong')).rejects.toThrow(
91 'Login failed: No session data received'
92 );
93 });
94 });
95
96 describe('logout', () => {
97 it('should logout and clear session', async () => {
98 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata);
99
100 await client.logout();
101
102 expect(vi.mocked(sessionModule.deleteSession)).toHaveBeenCalledWith(mockSessionMetadata.did);
103 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled();
104 });
105
106 it('should throw error if no active session', async () => {
107 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null);
108
109 await expect(client.logout()).rejects.toThrow('No active session found');
110 });
111 });
112
113 describe('resumeSession', () => {
114 it('should resume session from stored data', async () => {
115 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata);
116 vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData);
117
118 const resumed = await client.resumeSession();
119
120 expect(resumed).toBe(true);
121 expect(vi.mocked(sessionModule.loadSession)).toHaveBeenCalledWith(mockSessionMetadata.did);
122 expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({
123 ...mockSessionMetadata,
124 lastUsed: expect.any(String),
125 });
126 });
127
128 it('should return false if no metadata exists', async () => {
129 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null);
130
131 const resumed = await client.resumeSession();
132
133 expect(resumed).toBe(false);
134 });
135
136 it('should return false and cleanup if session data is missing', async () => {
137 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata);
138 vi.mocked(sessionModule.loadSession).mockResolvedValue(null);
139
140 const resumed = await client.resumeSession();
141
142 expect(resumed).toBe(false);
143 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled();
144 });
145
146 it('should return false without clearing metadata on transient resume error', async () => {
147 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata);
148 vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData);
149
150 const agent = client.getAgent();
151 vi.mocked(agent.resumeSession).mockRejectedValueOnce(new Error('Resume failed'));
152
153 const resumed = await client.resumeSession();
154
155 expect(resumed).toBe(false);
156 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled();
157 });
158
159 it('should rethrow KeychainAccessError without clearing metadata', async () => {
160 vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValueOnce(
161 new KeychainAccessError('Cannot access keychain: locked')
162 );
163
164 await expect(client.resumeSession()).rejects.toThrow(KeychainAccessError);
165 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled();
166 });
167 });
168
169 describe('isAuthenticated', () => {
170 it('should return false when not authenticated', () => {
171 expect(client.isAuthenticated()).toBe(false);
172 });
173 });
174
175 describe('getAgent', () => {
176 it('should return the AtpAgent instance', () => {
177 const agent = client.getAgent();
178 expect(agent).toBeDefined();
179 });
180 });
181
182 describe('getSession', () => {
183 it('should return undefined when not authenticated', () => {
184 expect(client.getSession()).toBeUndefined();
185 });
186 });
187});