Files
wt 2c278044bb
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
delete CloudKit and added BalanceFlutter
2026-10-28 15:37:32 +07:00

256 lines
7.3 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:balance_flutter/services/sync_api.dart';
void main() {
const time = '2026-01-01T00:00:00Z';
WireMap change(int sequence) => {
'entity': 'transaction',
'id': 'id-$sequence',
'deleted': false,
'updatedAt': time,
'payload': {'amount': 1.23},
'sequence': sequence,
};
http.Response response(Object value, {int status = 200}) => http.Response(
jsonEncode(value),
status,
headers: {'content-type': 'application/json; charset=utf-8'},
);
WireMap page(
int cursor, {
bool more = false,
List<WireMap> changes = const [],
}) => {
'cursor': cursor,
'hasMore': more,
'changes': changes,
'serverTime': time,
};
Future<WireMap> pull(SyncApi api, {int cursor = 0}) => api.sync(
accessToken: 'test-token',
cursor: cursor,
deviceId: 'test-device',
changes: [],
);
test('auth uses native routes, trims email and preserves password', () async {
final requests = <http.Request>[];
final session = {
'accessToken': 'access',
'refreshToken': 'refresh',
'expiresAt': time,
'user': {'id': 'user', 'email': 'test@example.com'},
};
final api = SyncApi(
serverUrl: 'https://example.com/base/',
client: MockClient((request) async {
requests.add(request);
if (request.url.path.endsWith('/me')) return response(session['user']!);
if (request.url.path.endsWith('/logout')) return http.Response('', 204);
return response(session);
}),
);
expect(
(await api.register(' test@example.com ', ' password '))['accessToken'],
'access',
);
await api.login('test@example.com', ' password ');
await api.refresh('refresh');
await api.me('access');
await api.logout('refresh');
expect(requests.map((r) => r.url.path), [
'/base/v1/auth/register',
'/base/v1/auth/login',
'/base/v1/auth/refresh',
'/base/v1/me',
'/base/v1/auth/logout',
]);
expect(jsonDecode(requests.first.body), {
'email': 'test@example.com',
'password': ' password ',
});
expect(requests[3].method, 'GET');
expect(requests[3].headers['Authorization'], 'Bearer access');
expect(requests.every((r) => !r.followRedirects), isTrue);
});
test(
'synchronize batches push-only by 100 then applies ordered pull pages',
() async {
final sent = <WireMap>[];
var pullCount = 0;
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient((request) async {
final body = jsonDecode(request.body) as WireMap;
sent.add(body);
if (body['pull'] == false) return response(page(0));
pullCount++;
return response(
page(pullCount, more: pullCount == 1, changes: [change(pullCount)]),
);
}),
);
final applied = <int>[];
final outgoing = List.generate(205, (i) => change(i)..remove('sequence'));
final result = await api.synchronize(
accessToken: () async => 'token',
cursor: 0,
deviceId: 'device',
changes: outgoing,
applyPage: (changes, cursor) async {
expect(changes.single['sequence'], cursor);
applied.add(cursor);
},
);
expect(sent.take(3).map((p) => (p['changes'] as List).length), [
100,
100,
5,
]);
expect(
sent.take(3).every((p) => p['pull'] == false && p['cursor'] == 0),
isTrue,
);
expect(sent.skip(3).map((p) => p['cursor']), [0, 1]);
expect(applied, [1, 2]);
expect(result, 2);
},
);
test('failed durable apply stops pagination', () async {
var calls = 0;
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient((_) async {
calls++;
return response(page(1, more: true, changes: [change(1)]));
}),
);
await expectLater(
api.synchronize(
accessToken: () async => 'token',
cursor: 0,
deviceId: 'device',
changes: [],
applyPage: (_, _) async => throw StateError('disk full'),
),
throwsStateError,
);
expect(calls, 1);
});
test('invalid response cursors, sequence and shape are rejected', () async {
final invalidPages = <Object>[
page(0, more: true),
page(1),
page(2, changes: [change(1)]),
page(1, changes: [change(1), change(1)]),
{...page(0), 'hasMore': 'yes'},
{...page(0), 'cursor': 0.5},
{...page(0), 'serverTime': 'not-a-date'},
[],
];
for (final invalid in invalidPages) {
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient((_) async => response(invalid)),
);
await expectLater(pull(api), throwsFormatException, reason: '$invalid');
}
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient((_) async => response(page(0))),
);
await expectLater(pull(api, cursor: 1), throwsFormatException);
});
test(
'HTTP errors preserve structured status and discard raw proxy bodies',
() async {
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient(
(_) async => response({
'error': {'code': 'invalid_token', 'message': 'Expired'},
}, status: 401),
),
);
await expectLater(
pull(api),
throwsA(
isA<SyncApiException>()
.having((e) => e.statusCode, 'status', 401)
.having((e) => e.code, 'code', 'invalid_token'),
),
);
final proxy = SyncApi(
serverUrl: 'https://example.com',
client: MockClient(
(_) async =>
http.Response('<html>private proxy diagnostics</html>', 502),
),
);
await expectLater(
pull(proxy),
throwsA(
isA<SyncApiException>().having(
(e) => e.toString().contains('private'),
'no raw response body',
isFalse,
),
),
);
},
);
test('malformed sessions and unsafe server addresses are rejected', () async {
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient((_) async => response({'accessToken': 'token'})),
);
await expectLater(api.login('a@b.com', 'password'), throwsFormatException);
for (final url in [
'http://example.com',
'https://user:pass@example.com',
'https://example.com?token=x',
'file:///tmp/test',
]) {
expect(() => SyncApi(serverUrl: url), throwsArgumentError);
}
expect(
SyncApi(
serverUrl: 'http://127.0.0.1:8080',
allowInsecureHttp: true,
).baseUri.scheme,
'http',
);
});
test('invalid outgoing changes fail before any HTTP call', () async {
var calls = 0;
final api = SyncApi(
serverUrl: 'https://example.com',
client: MockClient((_) async {
calls++;
return response(page(0));
}),
);
final invalid = change(1)..['entity'] = 'unknown';
await expectLater(
api.sync(
accessToken: 'token',
cursor: 0,
deviceId: 'device',
changes: [invalid],
),
throwsFormatException,
);
expect(calls, 0);
});
}