Skip to content

Commit 6d5bc13

Browse files
committed
Add getCategoryMappings function and related tests
- Implemented getCategoryMappings to retrieve mappings of old category IDs to merged category IDs, handling cases where the database is not initialized or the category_mapping table does not exist. - Updated getTransactions and getBudgetedAmounts functions to resolve category IDs through the merge chain, ensuring accurate category representation in transactions and budget calculations. - Added unit tests for getCategoryMappings to verify its behavior under various scenarios, including database initialization and table existence. - Enhanced data transformation tests to ensure transactions are correctly categorized under merged categories.
1 parent 39a4fd6 commit 6d5bc13

File tree

3 files changed

+187
-11
lines changed

3 files changed

+187
-11
lines changed

src/services/fileApi.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getPayees,
1515
getAllTransactionsForYear,
1616
getCategoryGroupTombstones,
17+
getCategoryMappings,
1718
shutdown,
1819
clearBudget,
1920
integerToAmount,
@@ -365,6 +366,50 @@ describe('fileApi', () => {
365366
});
366367
});
367368

369+
describe('getCategoryMappings', () => {
370+
it('throws DatabaseError when database is not initialized', async () => {
371+
await expect(getCategoryMappings()).rejects.toThrow(DatabaseError);
372+
});
373+
374+
it('returns map of old category IDs to merged category IDs', async () => {
375+
const file = createMockFile();
376+
Object.defineProperty(file, 'size', { value: 1000, writable: false });
377+
378+
await initialize(file);
379+
380+
mockStatement.step
381+
.mockReturnValueOnce(true)
382+
.mockReturnValueOnce(true)
383+
.mockReturnValueOnce(false);
384+
mockStatement.getAsObject
385+
.mockReturnValueOnce({ id: 'cat1', transferId: 'cat2' }) // Merged: cat1 -> cat2
386+
.mockReturnValueOnce({ id: 'cat3', transferId: 'cat3' }); // Self-reference (not merged)
387+
388+
const categoryMappings = await getCategoryMappings();
389+
390+
expect(categoryMappings.size).toBe(1);
391+
expect(categoryMappings.get('cat1')).toBe('cat2');
392+
expect(categoryMappings.has('cat3')).toBe(false); // Self-references excluded
393+
expect(mockDatabase.prepare).toHaveBeenCalledWith(
394+
'SELECT id, transferId FROM category_mapping',
395+
);
396+
});
397+
398+
it('returns empty map when category_mapping table does not exist', async () => {
399+
const file = createMockFile();
400+
Object.defineProperty(file, 'size', { value: 1000, writable: false });
401+
402+
await initialize(file);
403+
mockDatabase.prepare.mockImplementation(() => {
404+
throw new Error('no such table: category_mapping');
405+
});
406+
407+
const categoryMappings = await getCategoryMappings();
408+
409+
expect(categoryMappings.size).toBe(0);
410+
});
411+
});
412+
368413
describe('getPayees', () => {
369414
it('throws DatabaseError when database is not initialized', async () => {
370415
await expect(getPayees()).rejects.toThrow(DatabaseError);

src/services/fileApi.ts

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,32 @@ export async function getCategoryGroupTombstones(): Promise<Map<string, boolean>
189189
return groupTombstoneMap;
190190
}
191191

192+
/**
193+
* Get category mappings (category ID -> merged category ID)
194+
* Maps deleted/merged categories to the categories they were merged into
195+
*/
196+
export async function getCategoryMappings(): Promise<Map<string, string>> {
197+
if (!db) {
198+
throw new DatabaseError('Database not loaded. Call initialize() first.');
199+
}
200+
201+
const categoryMapping = new Map<string, string>();
202+
try {
203+
const mappings = query('SELECT id, transferId FROM category_mapping');
204+
mappings.forEach(m => {
205+
const oldId = String(m.id);
206+
const newId = String(m.transferId);
207+
// Only include actual merges (where id != transferId)
208+
if (oldId !== newId) {
209+
categoryMapping.set(oldId, newId);
210+
}
211+
});
212+
} catch {
213+
// No category_mapping table, that's okay
214+
}
215+
return categoryMapping;
216+
}
217+
192218
/**
193219
* Get categories from database
194220
*/
@@ -295,6 +321,22 @@ async function getTransactions(
295321
categories.map(c => [String(c.id), { name: String(c.name), tombstone: c.tombstone === 1 }]),
296322
);
297323

324+
// Get category mappings for resolving merged categories
325+
let categoryMappings = new Map<string, string>();
326+
try {
327+
const mappings = query('SELECT id, transferId FROM category_mapping');
328+
mappings.forEach(m => {
329+
const oldId = String(m.id);
330+
const newId = String(m.transferId);
331+
// Only include actual merges (where id != transferId)
332+
if (oldId !== newId) {
333+
categoryMappings.set(oldId, newId);
334+
}
335+
});
336+
} catch {
337+
// No category_mapping table, that's okay
338+
}
339+
298340
const result: Transaction[] = [];
299341

300342
for (const t of transactions) {
@@ -314,8 +356,20 @@ async function getTransactions(
314356
const payeeInfo = payeeId && payeeInfoMap ? payeeInfoMap.get(payeeId) : undefined;
315357
const payeeTombstone = payeeInfo ? payeeInfo.tombstone : false;
316358

317-
// Get category info (name and tombstone status)
318-
const categoryInfo = t.category ? categoryMap.get(String(t.category)) : undefined;
359+
// Resolve category ID through merge chain (handle transitive merges A -> B -> C)
360+
let resolvedCategoryId = t.category ? String(t.category) : undefined;
361+
362+
if (resolvedCategoryId && categoryMappings.has(resolvedCategoryId)) {
363+
// Follow the merge chain until we reach the final target
364+
const visited = new Set<string>(); // Prevent infinite loops in case of circular references
365+
while (categoryMappings.has(resolvedCategoryId) && !visited.has(resolvedCategoryId)) {
366+
visited.add(resolvedCategoryId);
367+
resolvedCategoryId = categoryMappings.get(resolvedCategoryId)!;
368+
}
369+
}
370+
371+
// Get category info from the resolved (merged) category
372+
const categoryInfo = resolvedCategoryId ? categoryMap.get(resolvedCategoryId) : undefined;
319373
const categoryName = categoryInfo ? categoryInfo.name : undefined;
320374
const categoryTombstone = categoryInfo ? categoryInfo.tombstone : false;
321375

@@ -328,7 +382,7 @@ async function getTransactions(
328382
payee_name: payeeName,
329383
payee_tombstone: payeeTombstone,
330384
notes: t.notes ? String(t.notes) : undefined,
331-
category: t.category ? String(t.category) : undefined,
385+
category: resolvedCategoryId, // Use resolved category ID
332386
category_name: categoryName,
333387
category_tombstone: categoryTombstone,
334388
cleared: t.cleared === 1 || false,
@@ -465,11 +519,28 @@ export async function getBudgetedAmounts(
465519
'December',
466520
];
467521

468-
// Convert to structured format
469-
const result: Array<{ categoryId: string; month: string; budgetedAmount: number }> = [];
522+
// Get category mappings for resolving merged categories
523+
let categoryMappings = new Map<string, string>();
524+
try {
525+
const mappings = query('SELECT id, transferId FROM category_mapping');
526+
mappings.forEach(m => {
527+
const oldId = String(m.id);
528+
const newId = String(m.transferId);
529+
// Only include actual merges (where id != transferId)
530+
if (oldId !== newId) {
531+
categoryMappings.set(oldId, newId);
532+
}
533+
});
534+
} catch {
535+
// No category_mapping table, that's okay
536+
}
537+
538+
// Resolve category IDs through merge chain and accumulate budgets
539+
// Use a map to combine budgets from merged categories
540+
const budgetMap = new Map<string, Map<string, number>>(); // resolvedCategoryId -> month -> amount
470541

471542
for (const row of budgetRows) {
472-
const categoryId = String(row.category || '');
543+
let categoryId = String(row.category || '');
473544
const monthInt = Number(row.month || 0);
474545
const amount = Number(row.amount || 0);
475546

@@ -478,6 +549,15 @@ export async function getBudgetedAmounts(
478549
continue;
479550
}
480551

552+
// Resolve category ID through merge chain (handle transitive merges A -> B -> C)
553+
if (categoryMappings.has(categoryId)) {
554+
const visited = new Set<string>(); // Prevent infinite loops
555+
while (categoryMappings.has(categoryId) && !visited.has(categoryId)) {
556+
visited.add(categoryId);
557+
categoryId = categoryMappings.get(categoryId)!;
558+
}
559+
}
560+
481561
// Convert month INTEGER (YYYYMM format) to month name
482562
// Extract month number from YYYYMM (e.g., 202501 -> 01 -> January)
483563
const monthNum = monthInt % 100; // Get last 2 digits
@@ -488,13 +568,27 @@ export async function getBudgetedAmounts(
488568
const monthStr = monthNames[monthNum - 1];
489569
const budgetedAmount = integerToAmount(amount);
490570

491-
result.push({
492-
categoryId,
493-
month: monthStr,
494-
budgetedAmount,
495-
});
571+
// Accumulate budgets for the same resolved category and month
572+
if (!budgetMap.has(categoryId)) {
573+
budgetMap.set(categoryId, new Map());
574+
}
575+
const monthMap = budgetMap.get(categoryId)!;
576+
const currentAmount = monthMap.get(monthStr) || 0;
577+
monthMap.set(monthStr, currentAmount + budgetedAmount);
496578
}
497579

580+
// Convert map back to array format
581+
const result: Array<{ categoryId: string; month: string; budgetedAmount: number }> = [];
582+
budgetMap.forEach((monthMap, categoryId) => {
583+
monthMap.forEach((budgetedAmount, month) => {
584+
result.push({
585+
categoryId,
586+
month,
587+
budgetedAmount,
588+
});
589+
});
590+
});
591+
498592
return result;
499593
} catch (error) {
500594
// If budget data fetch fails (e.g., table doesn't exist), return empty array

src/utils/dataTransform.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,43 @@ describe('transformToWrappedData', () => {
344344
expect(deleted?.categoryName).toBe('deleted: Old Category');
345345
});
346346

347+
it('handles merged categories - transactions appear under merged category', () => {
348+
// Category cat1 was merged into cat2
349+
// Transaction originally had category 'cat1', but after resolution should use 'cat2'
350+
const transactions: Transaction[] = [
351+
createMockTransaction({ id: 't1', category: 'cat2', amount: -10000 }), // Already resolved to cat2
352+
];
353+
354+
const categories: Category[] = [
355+
createMockCategory({ id: 'cat1', name: 'Old Category', tombstone: true }),
356+
createMockCategory({ id: 'cat2', name: 'New Category', tombstone: false }),
357+
];
358+
359+
const result = transformToWrappedData(transactions, categories, [], []);
360+
361+
// Transaction should appear under merged category (cat2), not as "deleted: Old Category"
362+
const merged = result.topCategories.find(c => c.categoryId === 'cat2');
363+
expect(merged?.categoryName).toBe('New Category'); // Should show merged category name
364+
expect(merged?.amount).toBe(100);
365+
expect(result.topCategories.find(c => c.categoryId === 'cat1')).toBeUndefined();
366+
});
367+
368+
it('handles merged categories - non-merged deleted categories still show deleted prefix', () => {
369+
const transactions: Transaction[] = [
370+
createMockTransaction({ id: 't1', category: 'cat1', amount: -10000 }),
371+
];
372+
373+
const categories: Category[] = [
374+
createMockCategory({ id: 'cat1', name: 'Deleted Category', tombstone: true }),
375+
];
376+
377+
const result = transformToWrappedData(transactions, categories, [], []);
378+
379+
// Non-merged deleted category should still show "deleted: " prefix
380+
const deleted = result.topCategories.find(c => c.categoryId === 'cat1');
381+
expect(deleted?.categoryName).toBe('deleted: Deleted Category');
382+
});
383+
347384
it('sorts categories by amount descending', () => {
348385
const transactions: Transaction[] = [
349386
createMockTransaction({ id: 't1', category: 'cat1', amount: -10000 }),

0 commit comments

Comments
 (0)