sqlite-export-for-ynab 2.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/_main_test.py ADDED
@@ -0,0 +1,1125 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+ from unittest.mock import AsyncMock
6
+ from unittest.mock import Mock
7
+ from unittest.mock import patch
8
+
9
+ import aiosqlite
10
+ import fasteners
11
+ import pytest
12
+ import pytest_asyncio
13
+ from rich.progress import Progress
14
+
15
+ from sqlite_export_for_ynab import default_db_path
16
+ from sqlite_export_for_ynab._main import _ALL_RELATIONS
17
+ from sqlite_export_for_ynab._main import _Context
18
+ from sqlite_export_for_ynab._main import _context
19
+ from sqlite_export_for_ynab._main import _ENV_TOKEN
20
+ from sqlite_export_for_ynab._main import _get_plan_summaries
21
+ from sqlite_export_for_ynab._main import _PACKAGE
22
+ from sqlite_export_for_ynab._main import _PROGRESS_COLUMNS
23
+ from sqlite_export_for_ynab._main import async_main
24
+ from sqlite_export_for_ynab._main import asyncio_for_ynab
25
+ from sqlite_export_for_ynab._main import contents
26
+ from sqlite_export_for_ynab._main import get_last_knowledge_of_server
27
+ from sqlite_export_for_ynab._main import get_relations
28
+ from sqlite_export_for_ynab._main import insert_accounts
29
+ from sqlite_export_for_ynab._main import insert_category_groups
30
+ from sqlite_export_for_ynab._main import insert_entries
31
+ from sqlite_export_for_ynab._main import insert_payees
32
+ from sqlite_export_for_ynab._main import insert_plans
33
+ from sqlite_export_for_ynab._main import insert_scheduled_transactions
34
+ from sqlite_export_for_ynab._main import insert_transactions
35
+ from sqlite_export_for_ynab._main import main
36
+ from sqlite_export_for_ynab._main import resolve_token
37
+ from sqlite_export_for_ynab._main import sync
38
+ from testing.fixtures import ACCOUNT_ID_1
39
+ from testing.fixtures import ACCOUNT_ID_2
40
+ from testing.fixtures import ACCOUNTS
41
+ from testing.fixtures import accounts_response
42
+ from testing.fixtures import categories_response
43
+ from testing.fixtures import CATEGORY_GOAL_TARGET_DATE_1
44
+ from testing.fixtures import CATEGORY_GROUP_ID_1
45
+ from testing.fixtures import CATEGORY_GROUP_ID_2
46
+ from testing.fixtures import CATEGORY_GROUP_NAME_1
47
+ from testing.fixtures import CATEGORY_GROUP_NAME_2
48
+ from testing.fixtures import CATEGORY_GROUPS
49
+ from testing.fixtures import CATEGORY_ID_1
50
+ from testing.fixtures import CATEGORY_ID_2
51
+ from testing.fixtures import CATEGORY_ID_3
52
+ from testing.fixtures import CATEGORY_ID_4
53
+ from testing.fixtures import CATEGORY_NAME_1
54
+ from testing.fixtures import CATEGORY_NAME_2
55
+ from testing.fixtures import CATEGORY_NAME_3
56
+ from testing.fixtures import CATEGORY_NAME_4
57
+ from testing.fixtures import LKOS
58
+ from testing.fixtures import PAYEE_ID_1
59
+ from testing.fixtures import PAYEE_ID_2
60
+ from testing.fixtures import PAYEES
61
+ from testing.fixtures import payees_response
62
+ from testing.fixtures import PLAN_ID_1
63
+ from testing.fixtures import PLAN_ID_2
64
+ from testing.fixtures import plan_response
65
+ from testing.fixtures import PLANS
66
+ from testing.fixtures import SCHEDULED_SUBTRANSACTION_ID_1
67
+ from testing.fixtures import SCHEDULED_SUBTRANSACTION_ID_2
68
+ from testing.fixtures import SCHEDULED_TRANSACTION_ID_1
69
+ from testing.fixtures import SCHEDULED_TRANSACTION_ID_2
70
+ from testing.fixtures import SCHEDULED_TRANSACTION_ID_3
71
+ from testing.fixtures import SCHEDULED_TRANSACTIONS
72
+ from testing.fixtures import scheduled_transactions_response
73
+ from testing.fixtures import SERVER_KNOWLEDGE_1
74
+ from testing.fixtures import SUBTRANSACTION_ID_1
75
+ from testing.fixtures import SUBTRANSACTION_ID_2
76
+ from testing.fixtures import TOKEN
77
+ from testing.fixtures import TRANSACTION_ID_1
78
+ from testing.fixtures import TRANSACTION_ID_2
79
+ from testing.fixtures import TRANSACTION_ID_3
80
+ from testing.fixtures import TRANSACTIONS
81
+ from testing.fixtures import transactions_response
82
+
83
+
84
+ async def fetchall(con, query):
85
+ async with con.cursor() as cur:
86
+ await cur.execute(query)
87
+ return await cur.fetchall()
88
+
89
+
90
+ def assert_rows(rows, expected_rows):
91
+ assert len(rows) == len(expected_rows)
92
+ for row, expected in zip(rows, expected_rows, strict=True):
93
+ actual = dict(row)
94
+ assert {k: actual[k] for k in expected} == expected
95
+
96
+
97
+ @pytest_asyncio.fixture
98
+ async def context(tmp_path):
99
+ with Progress(*_PROGRESS_COLUMNS, disable=True) as progress:
100
+ async with aiosqlite.connect(":memory:") as con:
101
+ con.row_factory = aiosqlite.Row
102
+ await con.executescript(await contents("create-relations.sql"))
103
+ lock = fasteners.InterProcessLock(
104
+ tmp_path / "sqlite-export-for-ynab-test.lock"
105
+ )
106
+ yield _Context(progress, con, lock, Mock(spec=asyncio_for_ynab.ApiClient))
107
+
108
+
109
+ @pytest.mark.parametrize(
110
+ ("xdg_data_home", "expected_prefix"),
111
+ (
112
+ ("/tmp", Path("/tmp")),
113
+ ("", Path.home() / ".local" / "share"),
114
+ ),
115
+ )
116
+ def test_default_db_path(monkeypatch, xdg_data_home, expected_prefix):
117
+ monkeypatch.setenv("XDG_DATA_HOME", xdg_data_home)
118
+ assert default_db_path() == expected_prefix / "sqlite-export-for-ynab" / "db.sqlite"
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_get_relations(context):
123
+ async with context.con.cursor() as cur:
124
+ assert await get_relations(cur) == _ALL_RELATIONS
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_get_last_knowledge_of_server(context):
129
+ await insert_plans(context, PLANS, LKOS)
130
+ async with context.con.cursor() as cur:
131
+ assert await get_last_knowledge_of_server(cur) == LKOS
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_insert_plans(context):
136
+ await insert_plans(context, PLANS, LKOS)
137
+ assert_rows(
138
+ await fetchall(context.con, "SELECT * FROM plans ORDER BY name"),
139
+ [
140
+ {
141
+ "id": PLAN_ID_1,
142
+ "name": PLANS[0].name,
143
+ "currency_format_currency_symbol": "$",
144
+ "currency_format_decimal_digits": 2,
145
+ "currency_format_decimal_separator": ".",
146
+ "currency_format_display_symbol": 1,
147
+ "currency_format_group_separator": ",",
148
+ "currency_format_iso_code": "USD",
149
+ "currency_format_symbol_first": 1,
150
+ "last_knowledge_of_server": LKOS[PLAN_ID_1],
151
+ },
152
+ {
153
+ "id": PLAN_ID_2,
154
+ "name": PLANS[1].name,
155
+ "currency_format_currency_symbol": "$",
156
+ "currency_format_decimal_digits": 2,
157
+ "currency_format_decimal_separator": ".",
158
+ "currency_format_display_symbol": 1,
159
+ "currency_format_group_separator": ",",
160
+ "currency_format_iso_code": "USD",
161
+ "currency_format_symbol_first": 1,
162
+ "last_knowledge_of_server": LKOS[PLAN_ID_2],
163
+ },
164
+ ],
165
+ )
166
+
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_insert_accounts(context):
170
+ await insert_accounts(context, PLAN_ID_1, [])
171
+ assert not await fetchall(context.con, "SELECT * FROM accounts")
172
+ assert not await fetchall(context.con, "SELECT * FROM account_periodic_values")
173
+
174
+ await insert_accounts(context, PLAN_ID_1, ACCOUNTS)
175
+ assert_rows(
176
+ await fetchall(context.con, "SELECT * FROM accounts ORDER BY name"),
177
+ [
178
+ {
179
+ "id": ACCOUNT_ID_1,
180
+ "plan_id": PLAN_ID_1,
181
+ "name": ACCOUNTS[0].name,
182
+ "type": ACCOUNTS[0].type,
183
+ "balance": 160000,
184
+ "balance_formatted": "$160.00",
185
+ "balance_currency": 160.0,
186
+ "cleared_balance": 120000,
187
+ "cleared_balance_formatted": "$120.00",
188
+ "cleared_balance_currency": 120.0,
189
+ "closed": False,
190
+ "deleted": False,
191
+ "on_budget": True,
192
+ "transfer_payee_id": PAYEE_ID_1,
193
+ "uncleared_balance": 40000,
194
+ "uncleared_balance_formatted": "$40.00",
195
+ "uncleared_balance_currency": 40.0,
196
+ },
197
+ {
198
+ "id": ACCOUNT_ID_2,
199
+ "plan_id": PLAN_ID_1,
200
+ "name": ACCOUNTS[1].name,
201
+ "type": ACCOUNTS[1].type,
202
+ "balance": 25000,
203
+ "balance_formatted": "$25.00",
204
+ "balance_currency": 25.0,
205
+ "cleared_balance": 25000,
206
+ "cleared_balance_formatted": "$25.00",
207
+ "cleared_balance_currency": 25.0,
208
+ "closed": False,
209
+ "deleted": False,
210
+ "on_budget": True,
211
+ "transfer_payee_id": PAYEE_ID_2,
212
+ "uncleared_balance": 0,
213
+ "uncleared_balance_formatted": "$0.00",
214
+ "uncleared_balance_currency": 0.0,
215
+ },
216
+ ],
217
+ )
218
+
219
+ assert_rows(
220
+ await fetchall(
221
+ context.con, "SELECT * FROM account_periodic_values ORDER BY name"
222
+ ),
223
+ [
224
+ {
225
+ "account_id": ACCOUNT_ID_1,
226
+ "plan_id": PLAN_ID_1,
227
+ "name": "debt_escrow_amounts",
228
+ "date": "2024-01-01",
229
+ "amount": 160000,
230
+ },
231
+ {
232
+ "account_id": ACCOUNT_ID_1,
233
+ "plan_id": PLAN_ID_1,
234
+ "name": "debt_interest_rates",
235
+ "date": "2024-02-01",
236
+ "amount": 5000,
237
+ },
238
+ ],
239
+ )
240
+
241
+
242
+ @pytest.mark.asyncio
243
+ async def test_insert_category_groups(context):
244
+ await insert_category_groups(context, PLAN_ID_1, [])
245
+ assert not await fetchall(context.con, "SELECT * FROM category_groups")
246
+ assert not await fetchall(context.con, "SELECT * FROM categories")
247
+
248
+ await insert_category_groups(context, PLAN_ID_1, CATEGORY_GROUPS)
249
+ assert_rows(
250
+ await fetchall(context.con, "SELECT * FROM category_groups ORDER BY name"),
251
+ [
252
+ {
253
+ "id": CATEGORY_GROUP_ID_1,
254
+ "name": CATEGORY_GROUP_NAME_1,
255
+ "plan_id": PLAN_ID_1,
256
+ "hidden": False,
257
+ "internal": False,
258
+ "deleted": False,
259
+ },
260
+ {
261
+ "id": CATEGORY_GROUP_ID_2,
262
+ "name": CATEGORY_GROUP_NAME_2,
263
+ "plan_id": PLAN_ID_1,
264
+ "hidden": False,
265
+ "internal": False,
266
+ "deleted": False,
267
+ },
268
+ ],
269
+ )
270
+
271
+ assert_rows(
272
+ await fetchall(context.con, "SELECT * FROM categories ORDER BY name"),
273
+ [
274
+ {
275
+ "id": CATEGORY_ID_1,
276
+ "category_group_id": CATEGORY_GROUP_ID_1,
277
+ "category_group_name": CATEGORY_GROUP_NAME_1,
278
+ "plan_id": PLAN_ID_1,
279
+ "name": CATEGORY_NAME_1,
280
+ "hidden": False,
281
+ "internal": False,
282
+ "original_category_group_id": None,
283
+ "note": None,
284
+ "budgeted": 14500,
285
+ "balance_formatted": "$12.00",
286
+ "balance_currency": 12.0,
287
+ "activity": 2500,
288
+ "activity_formatted": "$2.50",
289
+ "activity_currency": 2.5,
290
+ "goal_type": None,
291
+ "goal_needs_whole_amount": None,
292
+ "goal_day": None,
293
+ "goal_cadence": None,
294
+ "goal_cadence_frequency": None,
295
+ "goal_creation_month": None,
296
+ "goal_snoozed_at": None,
297
+ "goal_target": 20000,
298
+ "budgeted_formatted": "$14.50",
299
+ "budgeted_currency": 14.5,
300
+ "goal_target_formatted": "$20.00",
301
+ "goal_target_currency": 20.0,
302
+ "goal_target_date": CATEGORY_GOAL_TARGET_DATE_1,
303
+ "goal_target_month": None,
304
+ "goal_percentage_complete": None,
305
+ "goal_months_to_budget": None,
306
+ "goal_under_funded": 8000,
307
+ "goal_under_funded_formatted": "$8.00",
308
+ "goal_under_funded_currency": 8.0,
309
+ "goal_overall_funded": 12000,
310
+ "goal_overall_funded_formatted": "$12.00",
311
+ "goal_overall_funded_currency": 12.0,
312
+ "goal_overall_left": 8000,
313
+ "goal_overall_left_formatted": "$8.00",
314
+ "goal_overall_left_currency": 8.0,
315
+ "deleted": False,
316
+ },
317
+ {
318
+ "id": CATEGORY_ID_2,
319
+ "category_group_id": CATEGORY_GROUP_ID_1,
320
+ "category_group_name": CATEGORY_GROUP_NAME_1,
321
+ "plan_id": PLAN_ID_1,
322
+ "name": CATEGORY_NAME_2,
323
+ "hidden": False,
324
+ "internal": False,
325
+ "original_category_group_id": None,
326
+ "note": None,
327
+ "budgeted": 10250,
328
+ "balance_formatted": "$9.25",
329
+ "balance_currency": 9.25,
330
+ "activity": 1000,
331
+ "activity_formatted": "$1.00",
332
+ "activity_currency": 1.0,
333
+ "goal_type": None,
334
+ "goal_needs_whole_amount": None,
335
+ "goal_day": None,
336
+ "goal_cadence": None,
337
+ "goal_cadence_frequency": None,
338
+ "goal_creation_month": None,
339
+ "goal_snoozed_at": None,
340
+ "goal_target": None,
341
+ "budgeted_formatted": "$10.25",
342
+ "budgeted_currency": 10.25,
343
+ "goal_target_formatted": None,
344
+ "goal_target_currency": None,
345
+ "goal_target_date": None,
346
+ "goal_target_month": None,
347
+ "goal_percentage_complete": None,
348
+ "goal_months_to_budget": None,
349
+ "goal_under_funded": None,
350
+ "goal_under_funded_formatted": None,
351
+ "goal_under_funded_currency": None,
352
+ "goal_overall_funded": None,
353
+ "goal_overall_funded_formatted": None,
354
+ "goal_overall_funded_currency": None,
355
+ "goal_overall_left": None,
356
+ "goal_overall_left_formatted": None,
357
+ "goal_overall_left_currency": None,
358
+ "deleted": False,
359
+ },
360
+ {
361
+ "id": CATEGORY_ID_3,
362
+ "category_group_id": CATEGORY_GROUP_ID_2,
363
+ "category_group_name": CATEGORY_GROUP_NAME_2,
364
+ "plan_id": PLAN_ID_1,
365
+ "name": CATEGORY_NAME_3,
366
+ "hidden": False,
367
+ "internal": False,
368
+ "original_category_group_id": None,
369
+ "note": None,
370
+ "budgeted": 15000,
371
+ "balance_formatted": "$7.50",
372
+ "balance_currency": 7.5,
373
+ "activity": 7500,
374
+ "activity_formatted": "$7.50",
375
+ "activity_currency": 7.5,
376
+ "goal_type": None,
377
+ "goal_needs_whole_amount": None,
378
+ "goal_day": None,
379
+ "goal_cadence": None,
380
+ "goal_cadence_frequency": None,
381
+ "goal_creation_month": None,
382
+ "goal_snoozed_at": None,
383
+ "goal_target": None,
384
+ "budgeted_formatted": "$15.00",
385
+ "budgeted_currency": 15.0,
386
+ "goal_target_formatted": None,
387
+ "goal_target_currency": None,
388
+ "goal_target_date": None,
389
+ "goal_target_month": None,
390
+ "goal_percentage_complete": None,
391
+ "goal_months_to_budget": None,
392
+ "goal_under_funded": None,
393
+ "goal_under_funded_formatted": None,
394
+ "goal_under_funded_currency": None,
395
+ "goal_overall_funded": None,
396
+ "goal_overall_funded_formatted": None,
397
+ "goal_overall_funded_currency": None,
398
+ "goal_overall_left": None,
399
+ "goal_overall_left_formatted": None,
400
+ "goal_overall_left_currency": None,
401
+ "deleted": False,
402
+ },
403
+ {
404
+ "id": CATEGORY_ID_4,
405
+ "category_group_id": CATEGORY_GROUP_ID_2,
406
+ "category_group_name": CATEGORY_GROUP_NAME_2,
407
+ "plan_id": PLAN_ID_1,
408
+ "name": CATEGORY_NAME_4,
409
+ "hidden": False,
410
+ "internal": False,
411
+ "original_category_group_id": None,
412
+ "note": None,
413
+ "budgeted": 20000,
414
+ "balance_formatted": "$19.00",
415
+ "balance_currency": 19.0,
416
+ "activity": 19000,
417
+ "activity_formatted": "$19.00",
418
+ "activity_currency": 19.0,
419
+ "goal_type": None,
420
+ "goal_needs_whole_amount": None,
421
+ "goal_day": None,
422
+ "goal_cadence": None,
423
+ "goal_cadence_frequency": None,
424
+ "goal_creation_month": None,
425
+ "goal_snoozed_at": None,
426
+ "goal_target": None,
427
+ "budgeted_formatted": "$20.00",
428
+ "budgeted_currency": 20.0,
429
+ "goal_target_formatted": None,
430
+ "goal_target_currency": None,
431
+ "goal_target_date": None,
432
+ "goal_target_month": None,
433
+ "goal_percentage_complete": None,
434
+ "goal_months_to_budget": None,
435
+ "goal_under_funded": None,
436
+ "goal_under_funded_formatted": None,
437
+ "goal_under_funded_currency": None,
438
+ "goal_overall_funded": None,
439
+ "goal_overall_funded_formatted": None,
440
+ "goal_overall_funded_currency": None,
441
+ "goal_overall_left": None,
442
+ "goal_overall_left_formatted": None,
443
+ "goal_overall_left_currency": None,
444
+ "deleted": False,
445
+ },
446
+ ],
447
+ )
448
+
449
+
450
+ @pytest.mark.asyncio
451
+ async def test_insert_category_group_without_categories(context):
452
+ await insert_category_groups(
453
+ context,
454
+ PLAN_ID_1,
455
+ [CATEGORY_GROUPS[0].model_copy(update={"categories": []})],
456
+ )
457
+
458
+ assert_rows(
459
+ await fetchall(context.con, "SELECT * FROM category_groups ORDER BY name"),
460
+ [
461
+ {
462
+ "id": CATEGORY_GROUP_ID_1,
463
+ "name": CATEGORY_GROUP_NAME_1,
464
+ "plan_id": PLAN_ID_1,
465
+ "hidden": False,
466
+ "internal": False,
467
+ "deleted": False,
468
+ },
469
+ ],
470
+ )
471
+ assert not await fetchall(context.con, "SELECT * FROM categories")
472
+
473
+
474
+ @pytest.mark.asyncio
475
+ async def test_insert_payees(context):
476
+ await insert_payees(context, PLAN_ID_1, [])
477
+ assert not await fetchall(context.con, "SELECT * FROM payees")
478
+
479
+ await insert_payees(context, PLAN_ID_1, PAYEES)
480
+ assert_rows(
481
+ await fetchall(context.con, "SELECT * FROM payees ORDER BY name"),
482
+ [
483
+ {
484
+ "id": PAYEE_ID_1,
485
+ "plan_id": PLAN_ID_1,
486
+ "name": PAYEES[0].name,
487
+ "transfer_account_id": None,
488
+ "deleted": False,
489
+ },
490
+ {
491
+ "id": PAYEE_ID_2,
492
+ "plan_id": PLAN_ID_1,
493
+ "name": PAYEES[1].name,
494
+ "transfer_account_id": None,
495
+ "deleted": False,
496
+ },
497
+ ],
498
+ )
499
+
500
+
501
+ @pytest.mark.asyncio
502
+ async def test_insert_entries_ignores_unknown_keys(context):
503
+ task_id = context.progress.add_task("Payees", total=1)
504
+ entry = {
505
+ "id": PAYEE_ID_1,
506
+ "name": "Payee",
507
+ "transfer_account_id": None,
508
+ "deleted": False,
509
+ "brand_new_api_field": "surprise",
510
+ }
511
+ await insert_entries(context, "payees", PLAN_ID_1, [entry], task_id)
512
+ assert_rows(
513
+ await fetchall(context.con, "SELECT * FROM payees"),
514
+ [
515
+ {
516
+ "id": PAYEE_ID_1,
517
+ "plan_id": PLAN_ID_1,
518
+ "name": "Payee",
519
+ "transfer_account_id": None,
520
+ "deleted": False,
521
+ }
522
+ ],
523
+ )
524
+
525
+
526
+ @pytest.mark.asyncio
527
+ async def test_insert_transactions(context):
528
+ await insert_transactions(context, PLAN_ID_1, [])
529
+ assert not await fetchall(context.con, "SELECT * FROM transactions")
530
+ assert not await fetchall(context.con, "SELECT * FROM subtransactions")
531
+
532
+ await insert_category_groups(context, PLAN_ID_1, CATEGORY_GROUPS)
533
+ await insert_transactions(context, PLAN_ID_1, TRANSACTIONS)
534
+ assert_rows(
535
+ await fetchall(context.con, "SELECT * FROM transactions ORDER BY date"),
536
+ [
537
+ {
538
+ "id": TRANSACTION_ID_1,
539
+ "plan_id": PLAN_ID_1,
540
+ "account_id": ACCOUNT_ID_1,
541
+ "account_name": "Account 1",
542
+ "date": "2024-01-01",
543
+ "amount": -10000,
544
+ "amount_formatted": "$10.00",
545
+ "amount_currency": 10.0,
546
+ "approved": 1,
547
+ "category_id": CATEGORY_ID_3,
548
+ "category_name": CATEGORY_NAME_3,
549
+ "cleared": "cleared",
550
+ "deleted": False,
551
+ },
552
+ {
553
+ "id": TRANSACTION_ID_2,
554
+ "plan_id": PLAN_ID_1,
555
+ "account_id": ACCOUNT_ID_1,
556
+ "account_name": "Account 1",
557
+ "date": "2024-02-01",
558
+ "amount": -15000,
559
+ "amount_formatted": "$15.00",
560
+ "amount_currency": 15.0,
561
+ "approved": 1,
562
+ "category_id": CATEGORY_ID_2,
563
+ "category_name": CATEGORY_NAME_2,
564
+ "cleared": "cleared",
565
+ "deleted": True,
566
+ },
567
+ {
568
+ "id": TRANSACTION_ID_3,
569
+ "plan_id": PLAN_ID_1,
570
+ "account_id": ACCOUNT_ID_1,
571
+ "account_name": "Account 1",
572
+ "date": "2024-03-01",
573
+ "amount": -19000,
574
+ "amount_formatted": "$19.00",
575
+ "amount_currency": 19.0,
576
+ "approved": 0,
577
+ "category_id": CATEGORY_ID_4,
578
+ "category_name": CATEGORY_NAME_4,
579
+ "cleared": "uncleared",
580
+ "deleted": False,
581
+ },
582
+ ],
583
+ )
584
+
585
+ assert_rows(
586
+ await fetchall(context.con, "SELECT * FROM subtransactions ORDER BY amount"),
587
+ [
588
+ {
589
+ "id": SUBTRANSACTION_ID_1,
590
+ "transaction_id": TRANSACTION_ID_1,
591
+ "plan_id": PLAN_ID_1,
592
+ "amount": -7500,
593
+ "amount_formatted": "$7.50",
594
+ "amount_currency": 7.5,
595
+ "category_id": CATEGORY_ID_1,
596
+ "category_name": CATEGORY_NAME_1,
597
+ "deleted": False,
598
+ },
599
+ {
600
+ "id": SUBTRANSACTION_ID_2,
601
+ "transaction_id": TRANSACTION_ID_1,
602
+ "plan_id": PLAN_ID_1,
603
+ "amount": -2500,
604
+ "amount_formatted": "$2.50",
605
+ "amount_currency": 2.5,
606
+ "category_id": CATEGORY_ID_2,
607
+ "category_name": CATEGORY_NAME_2,
608
+ "deleted": False,
609
+ },
610
+ ],
611
+ )
612
+
613
+ assert_rows(
614
+ await fetchall(context.con, "SELECT * FROM flat_transactions ORDER BY amount"),
615
+ [
616
+ {
617
+ "transaction_id": TRANSACTION_ID_1,
618
+ "subtransaction_id": SUBTRANSACTION_ID_1,
619
+ "plan_id": PLAN_ID_1,
620
+ "account_id": ACCOUNT_ID_1,
621
+ "account_name": "Account 1",
622
+ "cleared": "cleared",
623
+ "date": "2024-01-01",
624
+ "debt_transaction_type": None,
625
+ "id": SUBTRANSACTION_ID_1,
626
+ "amount": -7500,
627
+ "amount_formatted": "$7.50",
628
+ "amount_currency": 7.5,
629
+ "category_id": CATEGORY_ID_1,
630
+ "category_name": CATEGORY_NAME_1,
631
+ "category_group_id": CATEGORY_GROUP_ID_1,
632
+ "category_group_name": CATEGORY_GROUP_NAME_1,
633
+ "flag_color": None,
634
+ "flag_name": None,
635
+ "import_id": None,
636
+ "import_payee_name": None,
637
+ "import_payee_name_original": None,
638
+ "matched_transaction_id": None,
639
+ "memo": None,
640
+ "payee_id": None,
641
+ "payee_name": None,
642
+ "transfer_account_id": None,
643
+ "transfer_transaction_id": None,
644
+ },
645
+ {
646
+ "transaction_id": TRANSACTION_ID_1,
647
+ "subtransaction_id": SUBTRANSACTION_ID_2,
648
+ "plan_id": PLAN_ID_1,
649
+ "account_id": ACCOUNT_ID_1,
650
+ "account_name": "Account 1",
651
+ "cleared": "cleared",
652
+ "date": "2024-01-01",
653
+ "debt_transaction_type": None,
654
+ "id": SUBTRANSACTION_ID_2,
655
+ "amount": -2500,
656
+ "amount_formatted": "$2.50",
657
+ "amount_currency": 2.5,
658
+ "category_id": CATEGORY_ID_2,
659
+ "category_name": CATEGORY_NAME_2,
660
+ "category_group_id": CATEGORY_GROUP_ID_1,
661
+ "category_group_name": CATEGORY_GROUP_NAME_1,
662
+ "flag_color": None,
663
+ "flag_name": None,
664
+ "import_id": None,
665
+ "import_payee_name": None,
666
+ "import_payee_name_original": None,
667
+ "matched_transaction_id": None,
668
+ "memo": None,
669
+ "payee_id": None,
670
+ "payee_name": None,
671
+ "transfer_account_id": None,
672
+ "transfer_transaction_id": None,
673
+ },
674
+ ],
675
+ )
676
+
677
+
678
+ @pytest.mark.asyncio
679
+ async def test_insert_scheduled_transactions(context):
680
+ await insert_scheduled_transactions(context, PLAN_ID_1, [])
681
+ assert not await fetchall(context.con, "SELECT * FROM scheduled_transactions")
682
+ assert not await fetchall(context.con, "SELECT * FROM scheduled_subtransactions")
683
+
684
+ await insert_category_groups(context, PLAN_ID_1, CATEGORY_GROUPS)
685
+ await insert_scheduled_transactions(context, PLAN_ID_1, SCHEDULED_TRANSACTIONS)
686
+ assert_rows(
687
+ await fetchall(
688
+ context.con, "SELECT * FROM scheduled_transactions ORDER BY amount"
689
+ ),
690
+ [
691
+ {
692
+ "id": SCHEDULED_TRANSACTION_ID_1,
693
+ "plan_id": PLAN_ID_1,
694
+ "account_id": ACCOUNT_ID_1,
695
+ "account_name": "Account 1",
696
+ "date_first": "2024-01-01",
697
+ "date_next": "2024-01-01",
698
+ "frequency": "monthly",
699
+ "amount": -12000,
700
+ "amount_formatted": "$12.00",
701
+ "amount_currency": 12.0,
702
+ "category_id": CATEGORY_ID_1,
703
+ "category_name": CATEGORY_NAME_1,
704
+ "flag_color": None,
705
+ "flag_name": None,
706
+ "deleted": False,
707
+ },
708
+ {
709
+ "id": SCHEDULED_TRANSACTION_ID_2,
710
+ "plan_id": PLAN_ID_1,
711
+ "account_id": ACCOUNT_ID_1,
712
+ "account_name": "Account 1",
713
+ "date_first": "2024-02-01",
714
+ "date_next": "2024-02-01",
715
+ "frequency": "yearly",
716
+ "amount": -11000,
717
+ "amount_formatted": "$11.00",
718
+ "amount_currency": 11.0,
719
+ "category_id": CATEGORY_ID_3,
720
+ "category_name": CATEGORY_NAME_3,
721
+ "flag_color": None,
722
+ "flag_name": None,
723
+ "deleted": True,
724
+ },
725
+ {
726
+ "id": SCHEDULED_TRANSACTION_ID_3,
727
+ "plan_id": PLAN_ID_1,
728
+ "account_id": ACCOUNT_ID_1,
729
+ "account_name": "Account 1",
730
+ "date_first": "2024-03-01",
731
+ "date_next": "2024-03-01",
732
+ "frequency": "everyOtherMonth",
733
+ "amount": -9000,
734
+ "amount_formatted": "$9.00",
735
+ "amount_currency": 9.0,
736
+ "category_id": CATEGORY_ID_4,
737
+ "category_name": CATEGORY_NAME_4,
738
+ "flag_color": None,
739
+ "flag_name": None,
740
+ "deleted": False,
741
+ },
742
+ ],
743
+ )
744
+
745
+ assert_rows(
746
+ await fetchall(
747
+ context.con, "SELECT * FROM scheduled_subtransactions ORDER BY amount"
748
+ ),
749
+ [
750
+ {
751
+ "id": SCHEDULED_SUBTRANSACTION_ID_1,
752
+ "scheduled_transaction_id": SCHEDULED_TRANSACTION_ID_1,
753
+ "plan_id": PLAN_ID_1,
754
+ "amount": -8040,
755
+ "amount_formatted": "$8.04",
756
+ "amount_currency": 8.04,
757
+ "category_id": CATEGORY_ID_2,
758
+ "category_name": CATEGORY_NAME_2,
759
+ "deleted": False,
760
+ },
761
+ {
762
+ "id": SCHEDULED_SUBTRANSACTION_ID_2,
763
+ "scheduled_transaction_id": SCHEDULED_TRANSACTION_ID_1,
764
+ "plan_id": PLAN_ID_1,
765
+ "amount": -2960,
766
+ "amount_formatted": "$2.96",
767
+ "amount_currency": 2.96,
768
+ "category_id": CATEGORY_ID_3,
769
+ "category_name": CATEGORY_NAME_3,
770
+ "deleted": False,
771
+ },
772
+ ],
773
+ )
774
+
775
+ assert_rows(
776
+ await fetchall(
777
+ context.con, "SELECT * FROM scheduled_flat_transactions ORDER BY amount"
778
+ ),
779
+ [
780
+ {
781
+ "transaction_id": SCHEDULED_TRANSACTION_ID_3,
782
+ "plan_id": PLAN_ID_1,
783
+ "account_id": ACCOUNT_ID_1,
784
+ "account_name": "Account 1",
785
+ "date_first": "2024-03-01",
786
+ "date_next": "2024-03-01",
787
+ "id": SCHEDULED_TRANSACTION_ID_3,
788
+ "frequency": "everyOtherMonth",
789
+ "amount": -9000,
790
+ "amount_formatted": "$9.00",
791
+ "amount_currency": 9.0,
792
+ "category_id": CATEGORY_ID_4,
793
+ "category_name": CATEGORY_NAME_4,
794
+ "category_group_id": CATEGORY_GROUP_ID_2,
795
+ "category_group_name": CATEGORY_GROUP_NAME_2,
796
+ "flag_color": None,
797
+ "flag_name": None,
798
+ "memo": None,
799
+ "payee_name": None,
800
+ "payee_id": None,
801
+ "transfer_account_id": None,
802
+ },
803
+ {
804
+ "transaction_id": SCHEDULED_TRANSACTION_ID_1,
805
+ "subtransaction_id": SCHEDULED_SUBTRANSACTION_ID_1,
806
+ "plan_id": PLAN_ID_1,
807
+ "account_id": ACCOUNT_ID_1,
808
+ "account_name": "Account 1",
809
+ "date_first": "2024-01-01",
810
+ "date_next": "2024-01-01",
811
+ "id": SCHEDULED_SUBTRANSACTION_ID_1,
812
+ "frequency": "monthly",
813
+ "amount": -8040,
814
+ "amount_formatted": "$8.04",
815
+ "amount_currency": 8.04,
816
+ "category_id": CATEGORY_ID_2,
817
+ "category_name": CATEGORY_NAME_2,
818
+ "category_group_id": CATEGORY_GROUP_ID_1,
819
+ "category_group_name": CATEGORY_GROUP_NAME_1,
820
+ "flag_color": None,
821
+ "flag_name": None,
822
+ "memo": None,
823
+ "payee_name": None,
824
+ "payee_id": None,
825
+ "transfer_account_id": None,
826
+ },
827
+ {
828
+ "transaction_id": SCHEDULED_TRANSACTION_ID_1,
829
+ "subtransaction_id": SCHEDULED_SUBTRANSACTION_ID_2,
830
+ "plan_id": PLAN_ID_1,
831
+ "account_id": ACCOUNT_ID_1,
832
+ "account_name": "Account 1",
833
+ "date_first": "2024-01-01",
834
+ "date_next": "2024-01-01",
835
+ "id": SCHEDULED_SUBTRANSACTION_ID_2,
836
+ "frequency": "monthly",
837
+ "amount": -2960,
838
+ "amount_formatted": "$2.96",
839
+ "amount_currency": 2.96,
840
+ "category_id": CATEGORY_ID_3,
841
+ "category_name": CATEGORY_NAME_3,
842
+ "category_group_id": CATEGORY_GROUP_ID_2,
843
+ "category_group_name": CATEGORY_GROUP_NAME_2,
844
+ "flag_color": None,
845
+ "flag_name": None,
846
+ "memo": None,
847
+ "payee_name": None,
848
+ "payee_id": None,
849
+ "transfer_account_id": None,
850
+ },
851
+ ],
852
+ )
853
+
854
+
855
+ @patch(
856
+ "sqlite_export_for_ynab._main.PlansApi.get_plans",
857
+ new=AsyncMock(side_effect=[RuntimeError("boom"), plan_response(PLANS)]),
858
+ )
859
+ @pytest.mark.asyncio
860
+ async def test_get_plan_summaries_retries():
861
+ assert await _get_plan_summaries(Mock(spec=asyncio_for_ynab.ApiClient)) == PLANS
862
+
863
+
864
+ @patch("sqlite_export_for_ynab._main.sync")
865
+ @pytest.mark.asyncio
866
+ async def test_async_main_parses_full_refresh_and_quiet(sync, tmp_path, monkeypatch):
867
+ monkeypatch.setenv(_ENV_TOKEN, TOKEN)
868
+
869
+ ret = await async_main(
870
+ ("--db", str(tmp_path / "db.sqlite"), "--full-refresh", "--quiet")
871
+ )
872
+
873
+ sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", True, quiet=True)
874
+ assert ret == 0
875
+
876
+
877
+ def test_main_version(capsys):
878
+ with open(Path(__file__).parent.parent / "pyproject.toml", "rb") as f:
879
+ data = tomllib.load(f)
880
+ expected_version = data["project"]["version"]
881
+
882
+ with pytest.raises(SystemExit) as excinfo:
883
+ main(("--version",))
884
+ assert excinfo.value.code == 0
885
+
886
+ out, _ = capsys.readouterr()
887
+ assert out == f"{_PACKAGE} {expected_version}\n"
888
+
889
+
890
+ @patch("sqlite_export_for_ynab._main.sync")
891
+ def test_main_ok(sync, tmp_path, monkeypatch):
892
+ monkeypatch.setenv(_ENV_TOKEN, TOKEN)
893
+
894
+ ret = main(("--db", str(tmp_path / "db.sqlite")))
895
+ sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=False)
896
+ assert ret == 0
897
+
898
+
899
+ def test_main_no_token(tmp_path, monkeypatch):
900
+ monkeypatch.setenv(_ENV_TOKEN, "")
901
+
902
+ with pytest.raises(ValueError):
903
+ main(("--db", str(tmp_path / "db.sqlite")))
904
+
905
+
906
+ @patch("sqlite_export_for_ynab._main.sync")
907
+ def test_main_uses_token_override(sync, tmp_path, monkeypatch):
908
+ monkeypatch.delenv(_ENV_TOKEN, raising=False)
909
+
910
+ ret = main(("--db", str(tmp_path / "db.sqlite")), token_override="override-token")
911
+
912
+ sync.assert_called_once_with(
913
+ "override-token", tmp_path / "db.sqlite", False, quiet=False
914
+ )
915
+ assert ret == 0
916
+
917
+
918
+ @patch("sqlite_export_for_ynab._main.sync")
919
+ def test_main_quiet(sync, tmp_path, monkeypatch):
920
+ monkeypatch.setenv(_ENV_TOKEN, TOKEN)
921
+
922
+ ret = main(("--db", str(tmp_path / "db.sqlite"), "--quiet"))
923
+
924
+ sync.assert_called_once_with(TOKEN, tmp_path / "db.sqlite", False, quiet=True)
925
+ assert ret == 0
926
+
927
+
928
+ def test_resolve_token_override(monkeypatch):
929
+ monkeypatch.delenv(_ENV_TOKEN, raising=False)
930
+
931
+ assert resolve_token("override-token") == "override-token"
932
+
933
+
934
+ def test_resolve_token_env(monkeypatch):
935
+ monkeypatch.setenv(_ENV_TOKEN, TOKEN)
936
+
937
+ assert resolve_token() == TOKEN
938
+
939
+
940
+ @patch(
941
+ "sqlite_export_for_ynab._main.fasteners.InterProcessLock.acquire",
942
+ autospec=True,
943
+ )
944
+ @patch("sqlite_export_for_ynab._main.asyncio.get_running_loop")
945
+ @pytest.mark.asyncio
946
+ async def test_sync_lock_times_out(mock_get_running_loop, mock_acquire, tmp_path):
947
+ class FakeLoop:
948
+ def __init__(self):
949
+ self._times = iter((0.0, 0.2))
950
+
951
+ def time(self):
952
+ return next(self._times)
953
+
954
+ mock_get_running_loop.return_value = FakeLoop()
955
+ mock_acquire.return_value = False
956
+
957
+ with pytest.raises(TimeoutError):
958
+ await _context(
959
+ tmp_path / "db.sqlite",
960
+ asyncio_for_ynab.Configuration(access_token=TOKEN),
961
+ quiet=True,
962
+ timeout=0.1,
963
+ ).__aenter__()
964
+
965
+ assert mock_acquire.call_count == 1
966
+ assert mock_acquire.call_args.args[1] is False
967
+
968
+
969
+ @patch(
970
+ "sqlite_export_for_ynab._main.fasteners.InterProcessLock.acquire",
971
+ autospec=True,
972
+ )
973
+ @patch("sqlite_export_for_ynab._main.fasteners.InterProcessLock.release", autospec=True)
974
+ @patch("sqlite_export_for_ynab._main.asyncio.sleep")
975
+ @patch("sqlite_export_for_ynab._main.asyncio.get_running_loop")
976
+ @pytest.mark.asyncio
977
+ async def test_context_retries_after_sleep(
978
+ mock_get_running_loop,
979
+ mock_sleep,
980
+ mock_release,
981
+ mock_acquire,
982
+ tmp_path,
983
+ ):
984
+ class FakeLoop:
985
+ def __init__(self):
986
+ self._times = iter((0.0, 0.0, 0.2))
987
+
988
+ def time(self):
989
+ return next(self._times)
990
+
991
+ mock_get_running_loop.return_value = FakeLoop()
992
+ mock_acquire.side_effect = [False, True]
993
+
994
+ async with _context(
995
+ tmp_path / "db.sqlite",
996
+ asyncio_for_ynab.Configuration(access_token=TOKEN),
997
+ quiet=True,
998
+ timeout=0.1,
999
+ ):
1000
+ pass
1001
+
1002
+ assert mock_acquire.call_count == 2
1003
+ assert mock_acquire.call_args_list[0].args[1] is False
1004
+ assert mock_sleep.call_count == 1
1005
+ assert mock_release.call_count == 1
1006
+
1007
+
1008
+ @pytest.mark.asyncio
1009
+ async def test_context_removes_lock_file(tmp_path):
1010
+ db = tmp_path / "db.sqlite"
1011
+ lock_path = tmp_path / "db.sqlite.lock"
1012
+
1013
+ async with _context(
1014
+ db, asyncio_for_ynab.Configuration(access_token=TOKEN), quiet=True
1015
+ ):
1016
+ assert lock_path.exists()
1017
+
1018
+ assert not lock_path.exists()
1019
+
1020
+
1021
+ @patch(
1022
+ "sqlite_export_for_ynab._main.PlansApi.get_plans",
1023
+ new=AsyncMock(return_value=plan_response(PLANS)),
1024
+ )
1025
+ @patch(
1026
+ "sqlite_export_for_ynab._main.AccountsApi.get_accounts",
1027
+ new=AsyncMock(return_value=accounts_response([], SERVER_KNOWLEDGE_1)),
1028
+ )
1029
+ @patch(
1030
+ "sqlite_export_for_ynab._main.CategoriesApi.get_categories",
1031
+ new=AsyncMock(return_value=categories_response([], SERVER_KNOWLEDGE_1)),
1032
+ )
1033
+ @patch(
1034
+ "sqlite_export_for_ynab._main.PayeesApi.get_payees",
1035
+ new=AsyncMock(return_value=payees_response([], SERVER_KNOWLEDGE_1)),
1036
+ )
1037
+ @patch(
1038
+ "sqlite_export_for_ynab._main.TransactionsApi.get_transactions",
1039
+ new=AsyncMock(return_value=transactions_response([], SERVER_KNOWLEDGE_1)),
1040
+ )
1041
+ @patch(
1042
+ "sqlite_export_for_ynab._main.ScheduledTransactionsApi.get_scheduled_transactions",
1043
+ new=AsyncMock(return_value=scheduled_transactions_response([], SERVER_KNOWLEDGE_1)),
1044
+ )
1045
+ @pytest.mark.asyncio
1046
+ async def test_sync_no_data(tmp_path):
1047
+ # create the db and tables to exercise all code branches
1048
+ db = tmp_path / "db.sqlite"
1049
+ async with aiosqlite.connect(db) as con:
1050
+ await con.executescript(await contents("create-relations.sql"))
1051
+
1052
+ await sync(TOKEN, db, False)
1053
+
1054
+
1055
+ @patch(
1056
+ "sqlite_export_for_ynab._main.PlansApi.get_plans",
1057
+ new=AsyncMock(return_value=plan_response(PLANS)),
1058
+ )
1059
+ @patch(
1060
+ "sqlite_export_for_ynab._main.AccountsApi.get_accounts",
1061
+ new=AsyncMock(return_value=accounts_response([], SERVER_KNOWLEDGE_1)),
1062
+ )
1063
+ @patch(
1064
+ "sqlite_export_for_ynab._main.CategoriesApi.get_categories",
1065
+ new=AsyncMock(return_value=categories_response([], SERVER_KNOWLEDGE_1)),
1066
+ )
1067
+ @patch(
1068
+ "sqlite_export_for_ynab._main.PayeesApi.get_payees",
1069
+ new=AsyncMock(return_value=payees_response([], SERVER_KNOWLEDGE_1)),
1070
+ )
1071
+ @patch(
1072
+ "sqlite_export_for_ynab._main.TransactionsApi.get_transactions",
1073
+ new=AsyncMock(return_value=transactions_response([], SERVER_KNOWLEDGE_1)),
1074
+ )
1075
+ @patch(
1076
+ "sqlite_export_for_ynab._main.ScheduledTransactionsApi.get_scheduled_transactions",
1077
+ new=AsyncMock(return_value=scheduled_transactions_response([], SERVER_KNOWLEDGE_1)),
1078
+ )
1079
+ @pytest.mark.asyncio
1080
+ async def test_sync_no_data_quiet(tmp_path, capsys):
1081
+ db = tmp_path / "db.sqlite"
1082
+ async with aiosqlite.connect(db) as con:
1083
+ await con.executescript(await contents("create-relations.sql"))
1084
+
1085
+ await sync(TOKEN, db, False, quiet=True)
1086
+
1087
+ out, err = capsys.readouterr()
1088
+ assert out == ""
1089
+ assert err == ""
1090
+
1091
+
1092
+ @patch(
1093
+ "sqlite_export_for_ynab._main.PlansApi.get_plans",
1094
+ new=AsyncMock(return_value=plan_response(PLANS)),
1095
+ )
1096
+ @patch(
1097
+ "sqlite_export_for_ynab._main.AccountsApi.get_accounts",
1098
+ new=AsyncMock(return_value=accounts_response(ACCOUNTS, SERVER_KNOWLEDGE_1)),
1099
+ )
1100
+ @patch(
1101
+ "sqlite_export_for_ynab._main.CategoriesApi.get_categories",
1102
+ new=AsyncMock(
1103
+ return_value=categories_response(CATEGORY_GROUPS, SERVER_KNOWLEDGE_1)
1104
+ ),
1105
+ )
1106
+ @patch(
1107
+ "sqlite_export_for_ynab._main.PayeesApi.get_payees",
1108
+ new=AsyncMock(return_value=payees_response(PAYEES, SERVER_KNOWLEDGE_1)),
1109
+ )
1110
+ @patch(
1111
+ "sqlite_export_for_ynab._main.TransactionsApi.get_transactions",
1112
+ new=AsyncMock(return_value=transactions_response(TRANSACTIONS, SERVER_KNOWLEDGE_1)),
1113
+ )
1114
+ @patch(
1115
+ "sqlite_export_for_ynab._main.ScheduledTransactionsApi.get_scheduled_transactions",
1116
+ new=AsyncMock(
1117
+ return_value=scheduled_transactions_response(
1118
+ SCHEDULED_TRANSACTIONS,
1119
+ SERVER_KNOWLEDGE_1,
1120
+ )
1121
+ ),
1122
+ )
1123
+ @pytest.mark.asyncio
1124
+ async def test_sync(tmp_path):
1125
+ await sync(TOKEN, tmp_path / "db.sqlite", True)