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