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! :)
at main 187 lines 6.6 kB view raw
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});