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
273 lines
8.8 KiB
Dart
273 lines
8.8 KiB
Dart
// Run against a disposable BalanceServer instance:
|
|
// dart run tool/integration_sync.dart http://127.0.0.1:18080
|
|
// Creates two test accounts; never use a production database.
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:balance_flutter/domain/finance.dart';
|
|
import 'package:balance_flutter/services/sync_api.dart';
|
|
|
|
void check(bool condition, String message) {
|
|
if (!condition) throw StateError(message);
|
|
}
|
|
|
|
Future<void> unauthorized(
|
|
Future<Object?> Function() action,
|
|
String message,
|
|
) async {
|
|
try {
|
|
await action();
|
|
} on SyncApiException catch (error) {
|
|
check(error.statusCode == 401, '$message: expected HTTP 401');
|
|
return;
|
|
}
|
|
throw StateError('$message: unexpectedly accepted');
|
|
}
|
|
|
|
Map<String, dynamic> wire(
|
|
String entity,
|
|
String id,
|
|
DateTime updatedAt,
|
|
Map<String, dynamic>? payload,
|
|
) => {
|
|
'entity': entity,
|
|
'id': id,
|
|
'updatedAt': updatedAt.toUtc().toIso8601String(),
|
|
'deleted': payload == null,
|
|
'payload': ?payload,
|
|
};
|
|
|
|
Future<void> main(List<String> arguments) async {
|
|
final url = arguments.isEmpty ? 'http://127.0.0.1:18080' : arguments.single;
|
|
final uri = Uri.parse(url);
|
|
check(
|
|
['localhost', '127.0.0.1', '::1'].contains(uri.host),
|
|
'Integration accounts must use a local disposable server',
|
|
);
|
|
final primary = SyncApi(serverUrl: url, allowInsecureHttp: true);
|
|
final secondDevice = SyncApi(serverUrl: url, allowInsecureHttp: true);
|
|
final outsider = SyncApi(serverUrl: url, allowInsecureHttp: true);
|
|
final suffix = DateTime.now().microsecondsSinceEpoch;
|
|
final email = 'flutter-integration-$suffix@example.com';
|
|
const password = 'Balance integration password 2026!';
|
|
final ledger = <String, Map<String, dynamic>>{};
|
|
final pages = <int>[];
|
|
var cursor = 0;
|
|
try {
|
|
final registered = await primary.register(email, password);
|
|
final user = await primary.me(registered['accessToken'] as String);
|
|
check(user['email'] == email, 'register/me account mismatch');
|
|
var session = await secondDevice.login(email, password);
|
|
check(
|
|
(session['user'] as Map)['id'] == user['id'],
|
|
'login account mismatch',
|
|
);
|
|
final other = await outsider.register(
|
|
'other-$suffix@example.com',
|
|
password,
|
|
);
|
|
final timestamp = DateTime.now().toUtc().subtract(
|
|
const Duration(seconds: 5),
|
|
);
|
|
final transactions = List.generate(
|
|
1100,
|
|
(index) => TransactionRecord(
|
|
id: 'transaction-$index',
|
|
amountMinor: 10000 + index,
|
|
date: timestamp,
|
|
categoryName: 'Тестовая категория 🍏',
|
|
updatedAt: timestamp,
|
|
note: 'UTF-8 roundtrip $index',
|
|
kindRawValue: index.isEven ? 'expense' : 'income',
|
|
),
|
|
);
|
|
final category = CategoryRecord(
|
|
id: 'integration-category',
|
|
name: 'Тестовая категория 🍏',
|
|
icon: 'cart.fill',
|
|
colorName: 'green',
|
|
emoji: '🍏',
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
);
|
|
final budget = BudgetRecord(
|
|
id: 'integration-budget',
|
|
categoryName: category.name,
|
|
limitMinor: 999999,
|
|
monthStart: DateTime.utc(timestamp.year, timestamp.month),
|
|
updatedAt: timestamp,
|
|
);
|
|
final changes = [
|
|
for (final record in transactions)
|
|
wire('transaction', record.id, record.updatedAt, record.payload()),
|
|
wire('category', category.id, category.updatedAt, category.payload()),
|
|
wire('budget', budget.id, budget.updatedAt, budget.payload()),
|
|
];
|
|
await primary.synchronize(
|
|
accessToken: () async => registered['accessToken'] as String,
|
|
cursor: 0,
|
|
deviceId: 'integration-device-one',
|
|
changes: changes,
|
|
applyPage: (_, _) async {},
|
|
);
|
|
cursor = await secondDevice.synchronize(
|
|
accessToken: () async => session['accessToken'] as String,
|
|
cursor: 0,
|
|
deviceId: 'integration-device-two',
|
|
changes: [],
|
|
applyPage: (page, nextCursor) async {
|
|
check(nextCursor > cursor, 'Non-advancing pagination cursor');
|
|
pages.add(page.length);
|
|
for (final change in page) {
|
|
ledger['${change['entity']}:${change['id']}'] = change;
|
|
}
|
|
cursor = nextCursor;
|
|
},
|
|
);
|
|
check(
|
|
jsonEncode(pages) == '[500,500,102]',
|
|
'Expected 3 pages [500,500,102], got $pages',
|
|
);
|
|
check(ledger.length == 1102, 'Missing records after pagination');
|
|
final roundtrip = ledger['transaction:transaction-99']!;
|
|
final decoded = TransactionRecord.fromPayload(
|
|
id: roundtrip['id'] as String,
|
|
updatedAt: DateTime.parse(roundtrip['updatedAt'] as String),
|
|
payload: Map<String, dynamic>.from(roundtrip['payload'] as Map),
|
|
);
|
|
check(
|
|
decoded.amountMinor == 10099 && decoded.categoryName == category.name,
|
|
'Money or UTF-8 payload changed',
|
|
);
|
|
final changedAt = DateTime.now().toUtc();
|
|
final edited = transactions.first.copyWith(
|
|
amountMinor: 1234567,
|
|
updatedAt: changedAt,
|
|
);
|
|
final updates = [
|
|
wire('transaction', edited.id, edited.updatedAt, edited.payload()),
|
|
wire('transaction', transactions[1].id, changedAt, null),
|
|
];
|
|
await primary.synchronize(
|
|
accessToken: () async => registered['accessToken'] as String,
|
|
cursor: cursor,
|
|
deviceId: 'integration-device-one',
|
|
changes: updates,
|
|
applyPage: (_, _) async {},
|
|
);
|
|
var updateCount = 0;
|
|
cursor = await secondDevice.synchronize(
|
|
accessToken: () async => session['accessToken'] as String,
|
|
cursor: cursor,
|
|
deviceId: 'integration-device-two',
|
|
changes: [],
|
|
applyPage: (page, nextCursor) async {
|
|
updateCount += page.length;
|
|
for (final change in page) {
|
|
ledger['${change['entity']}:${change['id']}'] = change;
|
|
}
|
|
cursor = nextCursor;
|
|
},
|
|
);
|
|
check(updateCount == 2, 'Expected update and tombstone');
|
|
check(
|
|
ledger['transaction:transaction-1']!['deleted'] == true,
|
|
'Deletion was not propagated',
|
|
);
|
|
final updatedPayload =
|
|
ledger['transaction:transaction-0']!['payload'] as Map;
|
|
check(
|
|
moneyToMinor(updatedPayload['amount'] as num) == 1234567,
|
|
'Update was not propagated',
|
|
);
|
|
final oldCursor = cursor;
|
|
// Replaying stale active rows must not overwrite the newer value/tombstone.
|
|
await primary.synchronize(
|
|
accessToken: () async => registered['accessToken'] as String,
|
|
cursor: cursor,
|
|
deviceId: 'integration-device-one',
|
|
changes: changes.take(2).toList(),
|
|
applyPage: (page, nextCursor) async {
|
|
check(
|
|
page.isEmpty && nextCursor == oldCursor,
|
|
'Stale replay changed LWW state',
|
|
);
|
|
},
|
|
);
|
|
// The second account shares record IDs but must see no first-account rows.
|
|
final isolated = await outsider.sync(
|
|
accessToken: other['accessToken'] as String,
|
|
cursor: 0,
|
|
deviceId: 'integration-outsider',
|
|
changes: [],
|
|
);
|
|
check(
|
|
(isolated['changes'] as List).isEmpty && isolated['cursor'] == 0,
|
|
'Cross-account data leak',
|
|
);
|
|
await outsider.sync(
|
|
accessToken: other['accessToken'] as String,
|
|
cursor: 0,
|
|
deviceId: 'integration-outsider',
|
|
pull: false,
|
|
changes: [
|
|
wire(
|
|
'transaction',
|
|
transactions.first.id,
|
|
changedAt,
|
|
transactions.first.copyWith(amountMinor: 1).payload(),
|
|
),
|
|
],
|
|
);
|
|
final unchanged = await primary.sync(
|
|
accessToken: registered['accessToken'] as String,
|
|
cursor: cursor,
|
|
deviceId: 'integration-device-one',
|
|
changes: [],
|
|
);
|
|
check(
|
|
(unchanged['changes'] as List).isEmpty,
|
|
'Other-account edit affected first account',
|
|
);
|
|
final oldRefresh = session['refreshToken'] as String;
|
|
session = await secondDevice.refresh(oldRefresh);
|
|
check(
|
|
session['refreshToken'] != oldRefresh,
|
|
'Refresh token was not rotated',
|
|
);
|
|
await unauthorized(
|
|
() => secondDevice.refresh(oldRefresh),
|
|
'Rotated refresh token',
|
|
);
|
|
check(
|
|
(await secondDevice.me(session['accessToken'] as String))['id'] ==
|
|
user['id'],
|
|
'Refreshed token invalid',
|
|
);
|
|
await secondDevice.logout(session['refreshToken'] as String);
|
|
await unauthorized(
|
|
() => secondDevice.refresh(session['refreshToken'] as String),
|
|
'Logged-out refresh token',
|
|
);
|
|
await primary.logout(registered['refreshToken'] as String);
|
|
await outsider.logout(other['refreshToken'] as String);
|
|
stdout.writeln(
|
|
jsonEncode({
|
|
'result': 'PASS',
|
|
'server': url,
|
|
'records': 1102,
|
|
'pullPages': pages,
|
|
'updateAndDelete': true,
|
|
'staleReplayLww': true,
|
|
'twoAccountIsolation': true,
|
|
'refreshRotation': true,
|
|
'logoutRevocation': true,
|
|
}),
|
|
);
|
|
} finally {
|
|
primary.close();
|
|
secondDevice.close();
|
|
outsider.close();
|
|
}
|
|
}
|