mirror of
https://github.com/we-promise/sure.git
synced 2026-06-09 04:39:05 +00:00
* feat(mobile): add transaction metadata editing * fix(mobile): preserve explicit metadata clears * fix(mobile): derive persisted tag metadata state * fix(mobile): avoid logging transaction details * fix(mobile): harden transaction edit sync Pass the edited transaction context through provider updates, refresh or fall back after empty update responses, surface field-level API errors, and avoid forced metadata refetches on every edit screen open. * fix(mobile): keep transaction edit selects ci-compatible Use the DropdownButtonFormField API supported by the Flutter version pinned in upstream mobile CI.
309 lines
9.4 KiB
Dart
309 lines
9.4 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sure_mobile/models/offline_transaction.dart';
|
|
import 'package:sure_mobile/models/transaction.dart';
|
|
|
|
void main() {
|
|
group('Transaction metadata', () {
|
|
test('parses merchant and tags from API response', () {
|
|
final transaction = Transaction.fromJson({
|
|
'id': 'tx_1',
|
|
'account': {'id': 'acct_1'},
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'classification': 'expense',
|
|
'notes': 'latte',
|
|
'category': {'id': 'cat_1', 'name': 'Dining'},
|
|
'merchant': {'id': 'merchant_1', 'name': 'Cafe'},
|
|
'tags': [
|
|
{'id': 'tag_1', 'name': 'Work'},
|
|
{'id': 'tag_2', 'name': 'Travel'},
|
|
],
|
|
});
|
|
|
|
expect(transaction.merchantId, 'merchant_1');
|
|
expect(transaction.merchantName, 'Cafe');
|
|
expect(transaction.tagIds, ['tag_1', 'tag_2']);
|
|
expect(transaction.tagNames, ['Work', 'Travel']);
|
|
});
|
|
|
|
test('round-trips merchant and tag metadata through offline maps', () {
|
|
final offlineTransaction = OfflineTransaction.fromTransaction(
|
|
Transaction(
|
|
id: 'tx_1',
|
|
accountId: 'acct_1',
|
|
name: 'Coffee',
|
|
date: '2026-06-01',
|
|
amount: r'$4.50',
|
|
currency: 'USD',
|
|
nature: 'expense',
|
|
merchantId: 'merchant_1',
|
|
merchantName: 'Cafe',
|
|
tagIds: const ['tag_1', 'tag_2'],
|
|
tagNames: const ['Work', 'Travel'],
|
|
),
|
|
localId: 'local_1',
|
|
);
|
|
|
|
final restored = OfflineTransaction.fromDatabaseMap(
|
|
offlineTransaction.toDatabaseMap(),
|
|
);
|
|
|
|
expect(restored.merchantId, 'merchant_1');
|
|
expect(restored.merchantName, 'Cafe');
|
|
expect(restored.tagIds, ['tag_1', 'tag_2']);
|
|
expect(restored.tagNames, ['Work', 'Travel']);
|
|
expect(restored.syncStatus, SyncStatus.synced);
|
|
});
|
|
|
|
test('preserves omitted tag state for stored rows without tag columns', () {
|
|
final restored = OfflineTransaction.fromDatabaseMap({
|
|
'server_id': 'tx_1',
|
|
'local_id': 'local_1',
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'notes': null,
|
|
'category_id': null,
|
|
'category_name': null,
|
|
'merchant_id': null,
|
|
'merchant_name': null,
|
|
'sync_status': 'synced',
|
|
'created_at': '2026-06-01T00:00:00.000',
|
|
'updated_at': '2026-06-01T00:00:00.000',
|
|
});
|
|
|
|
expect(restored.tagsProvided, false);
|
|
expect(restored.tagIds, isEmpty);
|
|
expect(restored.tagNames, isEmpty);
|
|
});
|
|
|
|
test('parses flat merchant and tag fields', () {
|
|
final transaction = Transaction.fromJson({
|
|
'id': 'tx_1',
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'merchant_id': 'merchant_1',
|
|
'merchant_name': 'Cafe',
|
|
'tag_ids': ['tag_1', 'tag_2'],
|
|
'tag_names': ['Work', 'Travel'],
|
|
});
|
|
|
|
expect(transaction.merchantId, 'merchant_1');
|
|
expect(transaction.merchantName, 'Cafe');
|
|
expect(transaction.tagIds, ['tag_1', 'tag_2']);
|
|
expect(transaction.tagNames, ['Work', 'Travel']);
|
|
});
|
|
|
|
test('normalizes mismatched flat tag name lengths', () {
|
|
final shortNames = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'tag_ids': ['tag_1', 'tag_2'],
|
|
'tag_names': ['Work'],
|
|
});
|
|
|
|
final longNames = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'tag_ids': ['tag_1'],
|
|
'tag_names': ['Work', 'Ignored'],
|
|
});
|
|
|
|
expect(shortNames.tagNames, ['Work', '']);
|
|
expect(shortNames.tagIds, ['tag_1', 'tag_2']);
|
|
expect(longNames.tagNames, ['Work']);
|
|
expect(longNames.tagIds, ['tag_1']);
|
|
});
|
|
|
|
test('filters blank flat tag ids while preserving id-name pairing', () {
|
|
final transaction = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'tag_ids': ['', 'tag_2'],
|
|
'tag_names': ['Ignored', 'Travel'],
|
|
});
|
|
|
|
expect(transaction.tagIds, ['tag_2']);
|
|
expect(transaction.tagNames, ['Travel']);
|
|
});
|
|
|
|
test('distinguishes omitted tags from explicitly empty tags', () {
|
|
final withoutTags = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
});
|
|
|
|
final clearedTags = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'tags': [],
|
|
});
|
|
|
|
expect(withoutTags.tagsProvided, false);
|
|
expect(clearedTags.tagsProvided, true);
|
|
});
|
|
|
|
test('distinguishes omitted metadata from explicitly cleared metadata', () {
|
|
final withoutMetadata = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
});
|
|
|
|
final clearedMetadata = Transaction.fromJson({
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'category': null,
|
|
'merchant': null,
|
|
});
|
|
|
|
expect(withoutMetadata.categoryProvided, false);
|
|
expect(withoutMetadata.merchantProvided, false);
|
|
expect(clearedMetadata.categoryProvided, true);
|
|
expect(clearedMetadata.categoryId, isNull);
|
|
expect(clearedMetadata.categoryName, isNull);
|
|
expect(clearedMetadata.merchantProvided, true);
|
|
expect(clearedMetadata.merchantId, isNull);
|
|
expect(clearedMetadata.merchantName, isNull);
|
|
});
|
|
|
|
test('server sync merge preserves omitted metadata and applies clears', () {
|
|
final existing = OfflineTransaction(
|
|
id: 'tx_1',
|
|
localId: 'local_1',
|
|
accountId: 'acct_1',
|
|
name: 'Coffee',
|
|
date: '2026-06-01',
|
|
amount: r'$4.50',
|
|
currency: 'USD',
|
|
nature: 'expense',
|
|
categoryId: 'cat_1',
|
|
categoryName: 'Dining',
|
|
merchantId: 'merchant_1',
|
|
merchantName: 'Cafe',
|
|
tagIds: const ['tag_1'],
|
|
tagNames: const ['Work'],
|
|
);
|
|
|
|
final omittedMetadata = existing.mergeServerTransaction(
|
|
Transaction.fromJson({
|
|
'id': 'tx_1',
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
}),
|
|
accountId: 'acct_1',
|
|
);
|
|
|
|
expect(omittedMetadata.categoryId, 'cat_1');
|
|
expect(omittedMetadata.categoryName, 'Dining');
|
|
expect(omittedMetadata.merchantId, 'merchant_1');
|
|
expect(omittedMetadata.merchantName, 'Cafe');
|
|
expect(omittedMetadata.tagIds, ['tag_1']);
|
|
expect(omittedMetadata.tagNames, ['Work']);
|
|
|
|
final clearedMetadata = existing.mergeServerTransaction(
|
|
Transaction.fromJson({
|
|
'id': 'tx_1',
|
|
'account_id': 'acct_1',
|
|
'name': 'Coffee',
|
|
'date': '2026-06-01',
|
|
'amount': r'$4.50',
|
|
'currency': 'USD',
|
|
'nature': 'expense',
|
|
'category': null,
|
|
'merchant': null,
|
|
'tags': [],
|
|
}),
|
|
accountId: 'acct_1',
|
|
);
|
|
|
|
expect(clearedMetadata.categoryId, isNull);
|
|
expect(clearedMetadata.categoryName, isNull);
|
|
expect(clearedMetadata.merchantId, isNull);
|
|
expect(clearedMetadata.merchantName, isNull);
|
|
expect(clearedMetadata.tagIds, isEmpty);
|
|
expect(clearedMetadata.tagNames, isEmpty);
|
|
});
|
|
|
|
test('submitted update fallback preserves account and unchanged labels',
|
|
() {
|
|
final existing = OfflineTransaction(
|
|
id: 'tx_1',
|
|
localId: 'local_1',
|
|
accountId: 'acct_1',
|
|
name: 'Coffee',
|
|
date: '2026-06-01',
|
|
amount: r'$4.50',
|
|
currency: 'USD',
|
|
nature: 'expense',
|
|
notes: 'latte',
|
|
categoryId: 'cat_1',
|
|
categoryName: 'Dining',
|
|
merchantId: 'merchant_1',
|
|
merchantName: 'Cafe',
|
|
tagIds: const ['tag_1', 'tag_2'],
|
|
tagNames: const ['Work', 'Travel'],
|
|
);
|
|
|
|
final updated = existing.toTransactionWithSubmittedUpdate(
|
|
name: 'Morning coffee',
|
|
notes: '',
|
|
categoryId: 'cat_2',
|
|
merchantId: 'merchant_1',
|
|
tagIds: const ['tag_2'],
|
|
);
|
|
|
|
expect(updated.id, 'tx_1');
|
|
expect(updated.accountId, 'acct_1');
|
|
expect(updated.name, 'Morning coffee');
|
|
expect(updated.notes, '');
|
|
expect(updated.categoryId, 'cat_2');
|
|
expect(updated.categoryName, isNull);
|
|
expect(updated.merchantId, 'merchant_1');
|
|
expect(updated.merchantName, 'Cafe');
|
|
expect(updated.tagIds, ['tag_2']);
|
|
expect(updated.tagNames, ['Travel']);
|
|
});
|
|
});
|
|
}
|