Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
521 lines
18 KiB
Dart
521 lines
18 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:balance_flutter/domain/finance.dart';
|
|
import 'package:balance_flutter/services/app_store.dart';
|
|
import 'package:balance_flutter/services/storage.dart';
|
|
import 'package:balance_flutter/services/sync_api.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
FlutterSecureStorage.setMockInitialValues({});
|
|
});
|
|
final date = DateTime.utc(2026, 1);
|
|
TransactionRecord transaction(String id) => TransactionRecord(
|
|
id: id,
|
|
amountMinor: 12345,
|
|
date: date,
|
|
categoryName: 'Еда',
|
|
updatedAt: date,
|
|
);
|
|
|
|
test('durable restart keeps edits and deletion tombstones', () async {
|
|
final storage = MemorySnapshotStorage();
|
|
final store = AppStore(storage: storage);
|
|
await store.initialize();
|
|
await store.saveTransaction(transaction('keep'));
|
|
await store.saveTransaction(transaction('delete'));
|
|
await store.deleteTransaction('delete');
|
|
final reopened = AppStore(storage: storage);
|
|
await reopened.initialize();
|
|
expect(reopened.transactions.single.id, 'keep');
|
|
expect(reopened.transactions.single.amountMinor, 12345);
|
|
expect(reopened.pendingChangeCount, 2);
|
|
expect(storage.values.values.single['tombstones'], hasLength(1));
|
|
reopened.dispose();
|
|
store.dispose();
|
|
});
|
|
|
|
test(
|
|
'category rename cascades to transactions and budgets in one snapshot',
|
|
() async {
|
|
final storage = MemorySnapshotStorage();
|
|
final store = AppStore(storage: storage);
|
|
await store.initialize();
|
|
final category = CategoryRecord(
|
|
id: 'cat',
|
|
name: 'Еда',
|
|
icon: 'cart.fill',
|
|
colorName: 'orange',
|
|
createdAt: date,
|
|
updatedAt: date,
|
|
);
|
|
await store.saveCategory(category);
|
|
await store.saveTransaction(transaction('tx'));
|
|
await store.saveBudget(
|
|
BudgetRecord(
|
|
id: 'budget',
|
|
categoryName: 'Еда',
|
|
limitMinor: 10000,
|
|
monthStart: date,
|
|
updatedAt: date,
|
|
),
|
|
);
|
|
await store.saveCategory(
|
|
category.copyWith(name: 'Продукты', emoji: '🍏'),
|
|
);
|
|
expect(store.transactions.single.categoryName, 'Продукты');
|
|
expect(store.budgets.single.categoryName, 'Продукты');
|
|
expect(store.transactions.single.categoryEmoji, '🍏');
|
|
expect(store.pendingChangeCount, 3);
|
|
store.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'pending delete survives newer remote edit and is queued with newer timestamp',
|
|
() async {
|
|
final storage = MemorySnapshotStorage();
|
|
final remoteTime = DateTime.now().toUtc().add(const Duration(seconds: 2));
|
|
var serveConflict = false;
|
|
final client = MockClient((request) async {
|
|
final body = jsonDecode(request.body) as Map;
|
|
if (request.url.path.endsWith('/login')) {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'accessToken': 'access',
|
|
'refreshToken': 'refresh',
|
|
'expiresAt': DateTime.now()
|
|
.toUtc()
|
|
.add(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'user': {'id': 'user', 'email': 'test@example.com'},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
}
|
|
final conflict = serveConflict && body['pull'] == true;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'cursor': conflict ? 1 : body['cursor'],
|
|
'hasMore': false,
|
|
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
|
'changes': conflict
|
|
? [
|
|
{
|
|
'entity': 'transaction',
|
|
'id': 'tx',
|
|
'deleted': false,
|
|
'updatedAt': remoteTime.toIso8601String(),
|
|
'sequence': 1,
|
|
'payload': transaction('tx').payload(),
|
|
},
|
|
]
|
|
: [],
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
});
|
|
final store = AppStore(
|
|
storage: storage,
|
|
apiFactory: (server) => SyncApi(serverUrl: server, client: client),
|
|
);
|
|
await store.initialize();
|
|
await store.setServerUrl('https://balance.example.com');
|
|
await store.signIn('test@example.com', 'password');
|
|
await store.saveTransaction(transaction('tx'));
|
|
await store.deleteTransaction('tx');
|
|
serveConflict = true;
|
|
await store.sync();
|
|
expect(store.transactions, isEmpty);
|
|
expect(store.pendingChangeCount, 1);
|
|
expect(store.cursor, 1);
|
|
final saved = storage.values['https://balance.example.com|user']!;
|
|
final pending = (saved['dirty'] as List).single as Map;
|
|
expect(pending['deleted'], true);
|
|
expect(
|
|
DateTime.parse(pending['updatedAt'] as String).isAfter(remoteTime),
|
|
true,
|
|
);
|
|
store.dispose();
|
|
client.close();
|
|
},
|
|
);
|
|
test(
|
|
'expense category deletion removes budgets and preserves history',
|
|
() async {
|
|
final store = AppStore(storage: MemorySnapshotStorage());
|
|
await store.initialize();
|
|
await store.saveCategory(
|
|
CategoryRecord(
|
|
id: 'cat',
|
|
name: 'Еда',
|
|
icon: 'cart.fill',
|
|
colorName: 'orange',
|
|
createdAt: date,
|
|
updatedAt: date,
|
|
),
|
|
);
|
|
await store.saveTransaction(transaction('tx'));
|
|
await store.saveBudget(
|
|
BudgetRecord(
|
|
id: 'budget',
|
|
categoryName: 'Еда',
|
|
limitMinor: 10000,
|
|
monthStart: date,
|
|
updatedAt: date,
|
|
),
|
|
);
|
|
await store.deleteCategory('cat');
|
|
expect(store.categories, isEmpty);
|
|
expect(store.budgets, isEmpty);
|
|
expect(store.transactions, hasLength(1));
|
|
expect(store.pendingChangeCount, 3);
|
|
store.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'failed pull commit preserves old cursor and dirty local changes',
|
|
() async {
|
|
final storage = FailingStorage();
|
|
var includeRemote = false;
|
|
final client = MockClient((request) async {
|
|
final body = jsonDecode(request.body) as Map;
|
|
if (request.url.path.endsWith('/login')) {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'accessToken': 'a',
|
|
'refreshToken': 'r',
|
|
'expiresAt': DateTime.now()
|
|
.toUtc()
|
|
.add(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'user': {'id': 'u', 'email': 'u@example.com'},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
}
|
|
final pull = body['pull'] == true && includeRemote;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'cursor': pull ? 1 : body['cursor'],
|
|
'hasMore': false,
|
|
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
|
'changes': pull
|
|
? [
|
|
{
|
|
'entity': 'transaction',
|
|
'id': 'remote',
|
|
'deleted': false,
|
|
'updatedAt': DateTime.now().toUtc().toIso8601String(),
|
|
'sequence': 1,
|
|
'payload': transaction('remote').payload(),
|
|
},
|
|
]
|
|
: [],
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
});
|
|
final store = AppStore(
|
|
storage: storage,
|
|
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
|
);
|
|
await store.initialize();
|
|
await store.setServerUrl('https://example.com');
|
|
await store.signIn('u@example.com', 'password');
|
|
await store.saveTransaction(transaction('local'));
|
|
includeRemote = true;
|
|
storage.failPage = true;
|
|
await expectLater(store.sync(), throwsStateError);
|
|
expect(store.cursor, 0);
|
|
expect(store.transactions.map((e) => e.id), ['local']);
|
|
expect(store.pendingChangeCount, 1);
|
|
expect(storage.values['https://example.com|u']!['cursor'], 0);
|
|
storage.failPage = false;
|
|
await store.sync();
|
|
expect(store.cursor, 1);
|
|
expect(store.transactions, hasLength(2));
|
|
expect(store.pendingChangeCount, 0);
|
|
store.dispose();
|
|
client.close();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'first account imports guest once; accounts and servers stay isolated',
|
|
() async {
|
|
final storage = MemorySnapshotStorage();
|
|
final client = MockClient((request) async {
|
|
final body = jsonDecode(request.body) as Map;
|
|
if (request.url.path.endsWith('/login')) {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'accessToken': 'a',
|
|
'refreshToken': 'r',
|
|
'expiresAt': DateTime.now()
|
|
.toUtc()
|
|
.add(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'user': {'id': body['email'], 'email': body['email']},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
}
|
|
if (request.url.path.endsWith('/logout')) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response(
|
|
jsonEncode({
|
|
'cursor': body['cursor'],
|
|
'changes': [],
|
|
'hasMore': false,
|
|
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
});
|
|
final store = AppStore(
|
|
storage: storage,
|
|
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
|
);
|
|
await store.initialize();
|
|
await store.setServerUrl('https://one.example.com');
|
|
await store.saveTransaction(transaction('guest'));
|
|
await store.signIn('one@example.com', 'password');
|
|
expect(store.transactions.single.id, 'guest');
|
|
await store.signOut();
|
|
await store.signIn('two@example.com', 'password');
|
|
expect(store.transactions, isEmpty);
|
|
await store.saveTransaction(transaction('second'));
|
|
await store.setServerUrl('https://two.example.com');
|
|
expect(store.session, isNull);
|
|
expect(store.cursor, 0);
|
|
expect(store.transactions, isEmpty);
|
|
await store.setServerUrl('https://one.example.com');
|
|
await store.signIn('two@example.com', 'password');
|
|
expect(store.transactions.single.id, 'second');
|
|
store.dispose();
|
|
client.close();
|
|
},
|
|
);
|
|
test(
|
|
'two stores reload latest ledger before writing instead of clobbering it',
|
|
() async {
|
|
final storage = MemorySnapshotStorage();
|
|
final first = AppStore(storage: storage, background: true);
|
|
final second = AppStore(storage: storage, background: true);
|
|
await first.initialize();
|
|
await second.initialize();
|
|
await first.saveTransaction(transaction('one'));
|
|
await second.saveTransaction(transaction('two'));
|
|
await first.saveTransaction(transaction('three'));
|
|
expect(first.transactions.map((t) => t.id).toSet(), {
|
|
'one',
|
|
'two',
|
|
'three',
|
|
});
|
|
await second.sync();
|
|
expect(second.transactions, hasLength(3));
|
|
await first.close();
|
|
await second.close();
|
|
first.dispose();
|
|
second.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'headless initialization skips automatic sync and reloads another engine logout',
|
|
() async {
|
|
final storage = MemorySnapshotStorage();
|
|
var syncRequests = 0;
|
|
final client = MockClient((request) async {
|
|
final body = jsonDecode(request.body) as Map;
|
|
if (request.url.path.endsWith('/login')) {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'accessToken': 'a',
|
|
'refreshToken': 'r',
|
|
'expiresAt': DateTime.now()
|
|
.toUtc()
|
|
.add(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'user': {'id': 'u', 'email': 'u@example.com'},
|
|
}),
|
|
200,
|
|
);
|
|
}
|
|
if (request.url.path.endsWith('/logout')) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
syncRequests++;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'cursor': body['cursor'],
|
|
'changes': [],
|
|
'hasMore': false,
|
|
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
final first = AppStore(
|
|
storage: storage,
|
|
background: true,
|
|
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
|
);
|
|
await first.initialize();
|
|
await first.setServerUrl('https://example.com');
|
|
await first.signIn('u@example.com', 'password');
|
|
await first.saveTransaction(transaction('private'));
|
|
final countBeforeInitialize = syncRequests;
|
|
final second = AppStore(
|
|
storage: storage,
|
|
background: true,
|
|
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
|
);
|
|
await second.initialize();
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(syncRequests, countBeforeInitialize);
|
|
expect(second.transactions.single.id, 'private');
|
|
await first.signOut();
|
|
await second.sync();
|
|
expect(second.session, isNull);
|
|
expect(second.transactions, isEmpty);
|
|
expect(syncRequests, countBeforeInitialize);
|
|
await first.close();
|
|
await second.close();
|
|
first.dispose();
|
|
second.dispose();
|
|
client.close();
|
|
},
|
|
);
|
|
test(
|
|
'second engine reloads rotated refresh session instead of reusing old token',
|
|
() async {
|
|
const server = 'https://example.com';
|
|
Map<String, dynamic> sessionData(String suffix, DateTime expiry) => {
|
|
'accessToken': 'access-$suffix',
|
|
'refreshToken': 'refresh-$suffix',
|
|
'expiresAt': expiry.toUtc().toIso8601String(),
|
|
'user': {'id': 'u', 'email': 'u@example.com'},
|
|
};
|
|
SharedPreferences.setMockInitialValues({'serverUrl': server});
|
|
FlutterSecureStorage.setMockInitialValues({
|
|
'balance.session.${Uri.encodeComponent(server)}': jsonEncode(
|
|
sessionData('old', DateTime.now().subtract(const Duration(hours: 1))),
|
|
),
|
|
});
|
|
var refreshes = 0;
|
|
final authorization = <String>[];
|
|
final client = MockClient((request) async {
|
|
final body = jsonDecode(request.body) as Map;
|
|
if (request.url.path.endsWith('/refresh')) {
|
|
refreshes++;
|
|
expect(body['refreshToken'], 'refresh-old');
|
|
return http.Response(
|
|
jsonEncode(
|
|
sessionData('new', DateTime.now().add(const Duration(hours: 1))),
|
|
),
|
|
200,
|
|
);
|
|
}
|
|
authorization.add(request.headers['authorization']!);
|
|
return http.Response(
|
|
jsonEncode({
|
|
'cursor': body['cursor'],
|
|
'changes': [],
|
|
'hasMore': false,
|
|
'serverTime': DateTime.now().toUtc().toIso8601String(),
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
final storage = MemorySnapshotStorage();
|
|
final first = AppStore(
|
|
storage: storage,
|
|
background: true,
|
|
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
|
);
|
|
final second = AppStore(
|
|
storage: storage,
|
|
background: true,
|
|
apiFactory: (url) => SyncApi(serverUrl: url, client: client),
|
|
);
|
|
await first.initialize();
|
|
await second.initialize();
|
|
await first.sync();
|
|
await second.sync();
|
|
expect(refreshes, 1);
|
|
expect(authorization, ['Bearer access-new', 'Bearer access-new']);
|
|
expect(second.session!.refreshToken, 'refresh-new');
|
|
await first.close();
|
|
await second.close();
|
|
first.dispose();
|
|
second.dispose();
|
|
client.close();
|
|
},
|
|
);
|
|
test(
|
|
'invalid server URL preserves the active account and its ledger',
|
|
() async {
|
|
final url = 'https://good.example';
|
|
SharedPreferences.setMockInitialValues({'serverUrl': url});
|
|
final credentials = {
|
|
'accessToken': 'access',
|
|
'refreshToken': 'refresh',
|
|
'expiresAt': DateTime.now()
|
|
.add(const Duration(hours: 1))
|
|
.toUtc()
|
|
.toIso8601String(),
|
|
'user': {'id': 'account', 'email': 'a@example.com'},
|
|
};
|
|
FlutterSecureStorage.setMockInitialValues({
|
|
'balance.session.${Uri.encodeComponent(url)}': jsonEncode(credentials),
|
|
});
|
|
final store = AppStore(
|
|
storage: MemorySnapshotStorage(),
|
|
background: true,
|
|
);
|
|
await store.initialize();
|
|
await store.saveTransaction(transaction('preserved'));
|
|
for (final invalid in [
|
|
'http://remote.example',
|
|
'https://name:secret@remote.example',
|
|
'https://remote.example?x=1',
|
|
'https://remote.example#fragment',
|
|
'/relative',
|
|
]) {
|
|
await expectLater(store.setServerUrl(invalid), throwsArgumentError);
|
|
expect(store.serverUrl, url);
|
|
expect(store.session!.userId, 'account');
|
|
expect(store.transactions.single.id, 'preserved');
|
|
}
|
|
await store.close();
|
|
store.dispose();
|
|
},
|
|
);
|
|
}
|
|
|
|
class FailingStorage extends MemorySnapshotStorage {
|
|
bool failPage = false;
|
|
@override
|
|
Future<void> write(String key, Map<String, dynamic> value) async {
|
|
if (failPage && value['cursor'] == 1) throw StateError('Disk full');
|
|
await super.write(key, value);
|
|
}
|
|
}
|