import io import json import os import sys import time import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from api import ( # noqa: E402 AuthRequiredError, QuotaExceededError, ReadestAPIError, ReadestClient, ) API_BASE = 'https://web.example.com/api' SUPABASE = 'https://sb.example.com' ANON_KEY = 'anon-key' class FakeTransport: """Records requests; replies from a queue of (status, json_body) tuples.""" def __init__(self): self.requests = [] self.responses = [] def queue(self, status, body): self.responses.append((status, body)) def __call__(self, request, timeout): data = request.data if hasattr(data, 'read'): data = data.read() self.requests.append( { 'method': request.get_method(), 'url': request.full_url, 'headers': {k.lower(): v for k, v in request.header_items()}, 'body': data, } ) status, body = self.responses.pop(0) payload = body if isinstance(body, bytes) else json.dumps(body).encode('utf-8') return status, payload def make_client(transport, tokens=None, saved=None): def on_tokens(t): if saved is not None: saved.append(t) return ReadestClient( api_base=API_BASE, supabase_url=SUPABASE, anon_key=ANON_KEY, tokens=tokens, on_tokens=on_tokens, transport=transport, ) def valid_tokens(expires_in=3600): return { 'access_token': 'at', 'refresh_token': 'rt', 'expires_at': int(time.time()) + expires_in, 'expires_in': expires_in, } class SignInTest(unittest.TestCase): def test_password_sign_in_stores_tokens(self): transport = FakeTransport() saved = [] transport.queue( 200, { 'access_token': 'new-at', 'refresh_token': 'new-rt', 'expires_at': 1234, 'expires_in': 3600, 'user': {'id': 'u1', 'email': 'a@b.c'}, }, ) client = make_client(transport, saved=saved) user = client.sign_in_password('a@b.c', 'pw') req = transport.requests[0] self.assertEqual(req['method'], 'POST') self.assertEqual(req['url'], f'{SUPABASE}/auth/v1/token?grant_type=password') self.assertEqual(req['headers']['apikey'], ANON_KEY) self.assertEqual(json.loads(req['body']), {'email': 'a@b.c', 'password': 'pw'}) self.assertEqual(user['id'], 'u1') self.assertEqual(saved[-1]['access_token'], 'new-at') def test_failed_sign_in_raises_with_message(self): transport = FakeTransport() transport.queue(400, {'error_description': 'Invalid login credentials'}) client = make_client(transport) with self.assertRaises(ReadestAPIError) as ctx: client.sign_in_password('a@b.c', 'bad') self.assertIn('Invalid login credentials', str(ctx.exception)) class TokenRefreshTest(unittest.TestCase): def test_fresh_token_not_refreshed(self): transport = FakeTransport() transport.queue(200, {'books': []}) client = make_client(transport, tokens=valid_tokens()) client.pull_books() self.assertEqual(len(transport.requests), 1) # no refresh round-trip def test_expiring_token_refreshed_before_call(self): transport = FakeTransport() saved = [] transport.queue( 200, {'access_token': 'at2', 'refresh_token': 'rt2', 'expires_at': 99, 'expires_in': 3600}, ) transport.queue(200, {'books': []}) tokens = valid_tokens() tokens['expires_at'] = int(time.time()) + 10 # nearly expired client = make_client(transport, tokens=tokens, saved=saved) client.pull_books() self.assertEqual( transport.requests[0]['url'], f'{SUPABASE}/auth/v1/token?grant_type=refresh_token', ) self.assertEqual(json.loads(transport.requests[0]['body']), {'refresh_token': 'rt'}) self.assertEqual(transport.requests[1]['headers']['authorization'], 'Bearer at2') self.assertEqual(saved[-1]['access_token'], 'at2') def test_no_tokens_raises_auth_required(self): client = make_client(FakeTransport()) with self.assertRaises(AuthRequiredError): client.pull_books() class SyncTest(unittest.TestCase): def test_pull_books(self): transport = FakeTransport() transport.queue(200, {'books': [{'book_hash': 'h1'}]}) client = make_client(transport, tokens=valid_tokens()) books = client.pull_books() req = transport.requests[0] self.assertEqual(req['method'], 'GET') self.assertEqual(req['url'], f'{API_BASE}/sync?type=books&since=0') self.assertEqual(req['headers']['authorization'], 'Bearer at') self.assertEqual(books, [{'book_hash': 'h1'}]) def test_push_books(self): transport = FakeTransport() transport.queue(200, {'books': [{'book_hash': 'h1'}]}) client = make_client(transport, tokens=valid_tokens()) client.push_books([{'hash': 'h1', 'title': 'T'}]) req = transport.requests[0] self.assertEqual(req['method'], 'POST') self.assertEqual(req['url'], f'{API_BASE}/sync') body = json.loads(req['body']) self.assertEqual(body['books'][0]['hash'], 'h1') self.assertEqual(body['notes'], []) self.assertEqual(body['configs'], []) class StorageTest(unittest.TestCase): def test_get_upload_url(self): transport = FakeTransport() transport.queue(200, {'uploadUrl': 'https://s3/put', 'fileKey': 'k'}) client = make_client(transport, tokens=valid_tokens()) res = client.get_upload_url('Readest/Books/h/h.epub', 5, 'h') body = json.loads(transport.requests[0]['body']) self.assertEqual(body['fileName'], 'Readest/Books/h/h.epub') self.assertEqual(body['fileSize'], 5) self.assertEqual(body['bookHash'], 'h') self.assertEqual(res['uploadUrl'], 'https://s3/put') def test_quota_exceeded(self): transport = FakeTransport() transport.queue(403, {'error': 'Insufficient storage quota', 'usage': 1}) client = make_client(transport, tokens=valid_tokens()) with self.assertRaises(QuotaExceededError): client.get_upload_url('f', 5, 'h') def test_list_files(self): transport = FakeTransport() transport.queue(200, {'files': [{'file_key': 'u/Readest/Books/h/h.epub'}]}) client = make_client(transport, tokens=valid_tokens()) files = client.list_files('h') req = transport.requests[0] self.assertEqual(req['method'], 'GET') self.assertEqual(req['url'], f'{API_BASE}/storage/list?bookHash=h') self.assertEqual(files, [{'file_key': 'u/Readest/Books/h/h.epub'}]) def test_delete_file(self): transport = FakeTransport() transport.queue(200, {'success': True}) client = make_client(transport, tokens=valid_tokens()) client.delete_file('u/Readest/Books/h/h.epub') req = transport.requests[0] self.assertEqual(req['method'], 'DELETE') self.assertEqual( req['url'], f'{API_BASE}/storage/delete?fileKey=u%2FReadest%2FBooks%2Fh%2Fh.epub', ) def test_put_file_sends_content_length(self): transport = FakeTransport() transport.queue(200, b'') client = make_client(transport, tokens=valid_tokens()) client.put_file('https://s3/put', io.BytesIO(b'12345'), 5) req = transport.requests[0] self.assertEqual(req['method'], 'PUT') self.assertEqual(req['headers']['content-length'], '5') self.assertEqual(req['body'], b'12345') def test_put_file_failure_raises(self): transport = FakeTransport() transport.queue(500, b'InternalError') client = make_client(transport, tokens=valid_tokens()) with self.assertRaises(ReadestAPIError): client.put_file('https://s3/put', io.BytesIO(b'x'), 1) if __name__ == '__main__': unittest.main()