mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
dart: split ten oversized test files by group
firebase_auth_rest_test.dart 525 -> 153 + _google 158 + _tokens 206 + _uid 141
sync_revisions_test.dart 451 -> 223 + _peers 228 + _backends 158
+ _order 165 + _persistence 215
sync_settings_screen_test 457 -> 207 + _backup 143 + _google 145
mirror_store_test.dart 452 -> 209 + _reads 172 + _fallback 196 + _revisions 244
firebase_client_test.dart 374 -> 198 + _writes 227
firebase_config_test.dart 360 -> 138 + _client 148 + _client_google 164
device_auth_test.dart 307 -> 128 + _poll 201
github_client_test.dart 291 -> 143 + _writes 201
sync_test.dart 269 -> 160 + _skips 175
Two traps this hit, both invisible to a line count:
- _FakePersistence is declared *after* main(), so a naive cut at the last
brace dropped it; sync_revisions_persistence_test.dart failed to load
until it was carried across. Audited every other split file for trailing
top-level declarations — this was the only one.
- Copying the preamble into each sibling left helpers the sibling never
uses. `dart analyze` flagged 12; all removed rather than left as dead
code, and the analyzer is now clean.
crdt_sync_dart 280 passed, sync_settings_ui 29 passed, github_device_auth
analyze clean — all matching a clean HEAD worktree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
parent
9d897c13f4
commit
200432a2b4
152
crdt_sync_dart/test/firebase_auth_rest_google_test.dart
Normal file
152
crdt_sync_dart/test/firebase_auth_rest_google_test.dart
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A fixed "now" so token expiry is deterministic rather than wall-clock.
|
||||||
|
final _now = DateTime.utc(2026, 8, 3, 12);
|
||||||
|
|
||||||
|
http.Response _json(int statusCode, Object body) =>
|
||||||
|
http.Response(jsonEncode(body), statusCode);
|
||||||
|
|
||||||
|
/// Builds a provider whose POSTs return [responses] in call order (each
|
||||||
|
/// either an [http.Response] or a thrown [http.ClientException]).
|
||||||
|
FirebaseTokenProvider _provider(
|
||||||
|
List<Object> responses, {
|
||||||
|
FirebaseCredentialStore? store,
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
var index = 0;
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
final response = responses[index];
|
||||||
|
index++;
|
||||||
|
if (response is http.ClientException) throw response;
|
||||||
|
return response as http.Response;
|
||||||
|
});
|
||||||
|
return FirebaseTokenProvider(
|
||||||
|
apiKey: 'fake-api-key',
|
||||||
|
store: store ?? InMemoryCredentialStore(),
|
||||||
|
httpClient: mock,
|
||||||
|
clock: () => now ?? _now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('signInWithGoogle', () {
|
||||||
|
test('stores the session returned by identitytoolkit', () async {
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await provider.signInWithGoogle(idToken: 'google-token');
|
||||||
|
|
||||||
|
final saved = await store.load();
|
||||||
|
expect(saved!.idToken, 'id-g');
|
||||||
|
expect(saved.refreshToken, 'refresh-g');
|
||||||
|
expect(saved.expiresAt, _now.add(const Duration(hours: 1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts the IdP credential form-encoded to signInWithIdp', () async {
|
||||||
|
// The identitytoolkit quirk worth pinning: the Google token travels in a
|
||||||
|
// form-encoded `postBody` string, not as a JSON field. Getting this
|
||||||
|
// wrong returns INVALID_IDP_RESPONSE, which reads like a bad token.
|
||||||
|
late http.Request captured;
|
||||||
|
final provider = FirebaseTokenProvider(
|
||||||
|
apiKey: 'fake-api-key',
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
httpClient: http_testing.MockClient((request) async {
|
||||||
|
captured = request;
|
||||||
|
return _json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
clock: () => _now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.signInWithGoogle(idToken: 'google-token');
|
||||||
|
|
||||||
|
expect(captured.url.path, contains('accounts:signInWithIdp'));
|
||||||
|
final body = jsonDecode(captured.body) as Map<String, dynamic>;
|
||||||
|
expect(body['postBody'], 'id_token=google-token&providerId=google.com');
|
||||||
|
expect(body['returnSecureToken'], isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports Google\'s reason on a rejected token', () async {
|
||||||
|
final provider = _provider([
|
||||||
|
_json(400, {
|
||||||
|
'error': {'message': 'INVALID_IDP_RESPONSE'},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
() => provider.signInWithGoogle(idToken: 'stale-token'),
|
||||||
|
throwsA(
|
||||||
|
isA<FirebaseAuthError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('INVALID_IDP_RESPONSE'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves no session stored when the token is rejected', () async {
|
||||||
|
// A failed Google sign-in must not look like a signed-in device: the
|
||||||
|
// next idToken() has to fail loudly rather than serve a stale token.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(400, {
|
||||||
|
'error': {'message': 'INVALID_IDP_RESPONSE'},
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
() => provider.signInWithGoogle(idToken: 'bad'),
|
||||||
|
throwsA(isA<FirebaseAuthError>()),
|
||||||
|
);
|
||||||
|
expect(await store.load(), isNull);
|
||||||
|
expect(await provider.hasSession(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the email Firebase reports', () async {
|
||||||
|
// A fresh install knows no email until Firebase answers with one, so
|
||||||
|
// this return value is what lets the device store its account. Reading
|
||||||
|
// the email from anywhere on-device instead would store an empty one.
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'email': 'signed-in@example.com',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await provider.signInWithGoogle(idToken: 'token'),
|
||||||
|
'signed-in@example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when the response carries no email', () async {
|
||||||
|
// Not fatal: the session is still valid, so sign-in succeeds and the
|
||||||
|
// caller simply has no address to display.
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await provider.signInWithGoogle(idToken: 'token'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -150,376 +150,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('signInWithGoogle', () {
|
|
||||||
test('stores the session returned by identitytoolkit', () async {
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await provider.signInWithGoogle(idToken: 'google-token');
|
|
||||||
|
|
||||||
final saved = await store.load();
|
|
||||||
expect(saved!.idToken, 'id-g');
|
|
||||||
expect(saved.refreshToken, 'refresh-g');
|
|
||||||
expect(saved.expiresAt, _now.add(const Duration(hours: 1)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('posts the IdP credential form-encoded to signInWithIdp', () async {
|
|
||||||
// The identitytoolkit quirk worth pinning: the Google token travels in a
|
|
||||||
// form-encoded `postBody` string, not as a JSON field. Getting this
|
|
||||||
// wrong returns INVALID_IDP_RESPONSE, which reads like a bad token.
|
|
||||||
late http.Request captured;
|
|
||||||
final provider = FirebaseTokenProvider(
|
|
||||||
apiKey: 'fake-api-key',
|
|
||||||
store: InMemoryCredentialStore(),
|
|
||||||
httpClient: http_testing.MockClient((request) async {
|
|
||||||
captured = request;
|
|
||||||
return _json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
clock: () => _now,
|
|
||||||
);
|
|
||||||
|
|
||||||
await provider.signInWithGoogle(idToken: 'google-token');
|
|
||||||
|
|
||||||
expect(captured.url.path, contains('accounts:signInWithIdp'));
|
|
||||||
final body = jsonDecode(captured.body) as Map<String, dynamic>;
|
|
||||||
expect(body['postBody'], 'id_token=google-token&providerId=google.com');
|
|
||||||
expect(body['returnSecureToken'], isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reports Google\'s reason on a rejected token', () async {
|
|
||||||
final provider = _provider([
|
|
||||||
_json(400, {
|
|
||||||
'error': {'message': 'INVALID_IDP_RESPONSE'},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
() => provider.signInWithGoogle(idToken: 'stale-token'),
|
|
||||||
throwsA(
|
|
||||||
isA<FirebaseAuthError>().having(
|
|
||||||
(e) => e.message,
|
|
||||||
'message',
|
|
||||||
contains('INVALID_IDP_RESPONSE'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves no session stored when the token is rejected', () async {
|
|
||||||
// A failed Google sign-in must not look like a signed-in device: the
|
|
||||||
// next idToken() has to fail loudly rather than serve a stale token.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(400, {
|
|
||||||
'error': {'message': 'INVALID_IDP_RESPONSE'},
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
() => provider.signInWithGoogle(idToken: 'bad'),
|
|
||||||
throwsA(isA<FirebaseAuthError>()),
|
|
||||||
);
|
|
||||||
expect(await store.load(), isNull);
|
|
||||||
expect(await provider.hasSession(), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns the email Firebase reports', () async {
|
|
||||||
// A fresh install knows no email until Firebase answers with one, so
|
|
||||||
// this return value is what lets the device store its account. Reading
|
|
||||||
// the email from anywhere on-device instead would store an empty one.
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'email': 'signed-in@example.com',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await provider.signInWithGoogle(idToken: 'token'),
|
|
||||||
'signed-in@example.com',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when the response carries no email', () async {
|
|
||||||
// Not fatal: the session is still valid, so sign-in succeeds and the
|
|
||||||
// caller simply has no address to display.
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(await provider.signInWithGoogle(idToken: 'token'), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects a session for a uid other than the expected one', () async {
|
|
||||||
// The mis-tapped-account-picker case. signInWithIdp signs in OR SIGNS
|
|
||||||
// UP, so an unlinked Google identity is accepted as a brand-new user
|
|
||||||
// rather than refused. Without this check the device would store that
|
|
||||||
// session and then be denied every read and write, permanently.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'localId': 'some-other-uid',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
() => provider.signInWithGoogle(
|
|
||||||
idToken: 'token-for-the-wrong-account',
|
|
||||||
expectedUid: 'the-right-uid',
|
|
||||||
),
|
|
||||||
throwsA(
|
|
||||||
isA<FirebaseAuthError>().having(
|
|
||||||
(e) => e.message,
|
|
||||||
'message',
|
|
||||||
allOf(contains('some-other-uid'), contains('the-right-uid')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
await store.load(),
|
|
||||||
isNull,
|
|
||||||
reason: 'a wrong-account session must not persist',
|
|
||||||
);
|
|
||||||
expect(await provider.hasSession(), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts a session whose uid matches the expected one', () async {
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'localId': 'the-right-uid',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await provider.signInWithGoogle(
|
|
||||||
idToken: 'token',
|
|
||||||
expectedUid: 'the-right-uid',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect((await store.load())!.idToken, 'id-g');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips the uid check when no uid is expected', () async {
|
|
||||||
// FirebaseProject.configFor leaves uid empty on the app path; comparing
|
|
||||||
// against '' would otherwise fail every sign-in.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'localId': 'whatever-uid',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await provider.signInWithGoogle(idToken: 'token', expectedUid: '');
|
|
||||||
|
|
||||||
expect((await store.load())!.idToken, 'id-g');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('its session refreshes like a password session', () async {
|
|
||||||
// signInWithIdp returns the same refresh-token shape as
|
|
||||||
// signInWithPassword, which is why the refresh path needed no changes.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-g',
|
|
||||||
'refreshToken': 'refresh-g',
|
|
||||||
// Already inside the refresh skew, so the next read must refresh.
|
|
||||||
'expiresIn': '60',
|
|
||||||
}),
|
|
||||||
_json(200, {
|
|
||||||
'id_token': 'id-g2',
|
|
||||||
'refresh_token': 'refresh-g2',
|
|
||||||
'expires_in': '3600',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await provider.signInWithGoogle(idToken: 'google-token');
|
|
||||||
expect(await provider.idToken(), 'id-g2');
|
|
||||||
expect((await store.load())!.refreshToken, 'refresh-g2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('idToken', () {
|
|
||||||
test('fails loudly when no session is stored', () {
|
|
||||||
// Never returns null or a stale token: a sync that quietly stops
|
|
||||||
// syncing is exactly the failure mode being designed out.
|
|
||||||
expect(
|
|
||||||
_provider([]).idToken,
|
|
||||||
throwsA(
|
|
||||||
isA<FirebaseAuthError>().having(
|
|
||||||
(e) => e.message,
|
|
||||||
'message',
|
|
||||||
contains('not signed in'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns the stored token without a network call', () async {
|
|
||||||
// An empty response list means any HTTP call would throw a range error.
|
|
||||||
final provider = _provider(
|
|
||||||
[],
|
|
||||||
store: InMemoryCredentialStore(_credentials()),
|
|
||||||
);
|
|
||||||
expect(await provider.idToken(), 'id-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'refreshes an expired token and persists the rotated refresh token',
|
|
||||||
() async {
|
|
||||||
final store = InMemoryCredentialStore(
|
|
||||||
_credentials(validFor: const Duration(minutes: -5)),
|
|
||||||
);
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'id_token': 'id-2',
|
|
||||||
'refresh_token': 'refresh-2',
|
|
||||||
'expires_in': '3600',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
expect(await provider.idToken(), 'id-2');
|
|
||||||
final saved = await store.load();
|
|
||||||
expect(saved!.refreshToken, 'refresh-2');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('fails loudly when the refresh token has been revoked', () async {
|
|
||||||
final provider = _provider(
|
|
||||||
[
|
|
||||||
_json(400, {
|
|
||||||
'error': {'message': 'TOKEN_EXPIRED'},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
store: InMemoryCredentialStore(
|
|
||||||
_credentials(validFor: const Duration(minutes: -5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
provider.idToken,
|
|
||||||
throwsA(
|
|
||||||
isA<FirebaseAuthError>().having(
|
|
||||||
(e) => e.message,
|
|
||||||
'message',
|
|
||||||
contains('TOKEN_EXPIRED'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('session lifecycle', () {
|
|
||||||
test('hasSession is false before sign-in and true after', () async {
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final provider = _provider([
|
|
||||||
_json(200, {
|
|
||||||
'idToken': 'id-1',
|
|
||||||
'refreshToken': 'refresh-1',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
expect(await provider.hasSession(), isFalse);
|
|
||||||
await provider.signIn(email: 'a@b.c', password: 'x');
|
|
||||||
expect(await provider.hasSession(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('signOut clears the store so the next idToken fails', () async {
|
|
||||||
final provider = _provider(
|
|
||||||
[],
|
|
||||||
store: InMemoryCredentialStore(_credentials()),
|
|
||||||
);
|
|
||||||
expect(await provider.idToken(), 'id-1');
|
|
||||||
await provider.signOut();
|
|
||||||
expect(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('close releases the HTTP client', () {
|
|
||||||
expect(_provider([]).close, returnsNormally);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('a refresh token the server rejects', () {
|
|
||||||
// Regression: hasSession() was a presence check, so a revoked token still
|
|
||||||
// read as "Connected" while every sync died with TOKEN_EXPIRED. Observed
|
|
||||||
// on the desktop todo build on 2026-08-11.
|
|
||||||
test(
|
|
||||||
'is cleared, so hasSession stops claiming the device is connected',
|
|
||||||
() async {
|
|
||||||
final store = InMemoryCredentialStore(
|
|
||||||
_credentials(validFor: const Duration(seconds: -1)),
|
|
||||||
);
|
|
||||||
final provider = _provider([
|
|
||||||
_json(400, {
|
|
||||||
'error': {'message': 'TOKEN_EXPIRED'},
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
|
||||||
expect(
|
|
||||||
await provider.hasSession(),
|
|
||||||
isFalse,
|
|
||||||
reason: 'a revoked session must not keep reporting as connected',
|
|
||||||
);
|
|
||||||
expect(await store.load(), isNull);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('survives a network error, which is transient', () async {
|
|
||||||
// The opposite failure: signing out whenever the wifi drops would need a
|
|
||||||
// manual sign-in on every device to recover.
|
|
||||||
final store = InMemoryCredentialStore(
|
|
||||||
_credentials(validFor: const Duration(seconds: -1)),
|
|
||||||
);
|
|
||||||
final provider = _provider([
|
|
||||||
http.ClientException('connection reset'),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
|
||||||
expect(
|
|
||||||
await provider.hasSession(),
|
|
||||||
isTrue,
|
|
||||||
reason: 'a transient failure must not discard a good refresh token',
|
|
||||||
);
|
|
||||||
expect(await store.load(), isNotNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('survives a 5xx, which is also transient', () async {
|
|
||||||
final store = InMemoryCredentialStore(
|
|
||||||
_credentials(validFor: const Duration(seconds: -1)),
|
|
||||||
);
|
|
||||||
final provider = _provider([
|
|
||||||
_json(503, {
|
|
||||||
'error': {'message': 'SERVICE_UNAVAILABLE'},
|
|
||||||
}),
|
|
||||||
], store: store);
|
|
||||||
|
|
||||||
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
|
||||||
expect(await provider.hasSession(), isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
206
crdt_sync_dart/test/firebase_auth_rest_tokens_test.dart
Normal file
206
crdt_sync_dart/test/firebase_auth_rest_tokens_test.dart
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A fixed "now" so token expiry is deterministic rather than wall-clock.
|
||||||
|
final _now = DateTime.utc(2026, 8, 3, 12);
|
||||||
|
|
||||||
|
http.Response _json(int statusCode, Object body) =>
|
||||||
|
http.Response(jsonEncode(body), statusCode);
|
||||||
|
|
||||||
|
/// Builds a provider whose POSTs return [responses] in call order (each
|
||||||
|
/// either an [http.Response] or a thrown [http.ClientException]).
|
||||||
|
FirebaseTokenProvider _provider(
|
||||||
|
List<Object> responses, {
|
||||||
|
FirebaseCredentialStore? store,
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
var index = 0;
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
final response = responses[index];
|
||||||
|
index++;
|
||||||
|
if (response is http.ClientException) throw response;
|
||||||
|
return response as http.Response;
|
||||||
|
});
|
||||||
|
return FirebaseTokenProvider(
|
||||||
|
apiKey: 'fake-api-key',
|
||||||
|
store: store ?? InMemoryCredentialStore(),
|
||||||
|
httpClient: mock,
|
||||||
|
clock: () => now ?? _now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FirebaseCredentials _credentials({
|
||||||
|
String idToken = 'id-1',
|
||||||
|
String refreshToken = 'refresh-1',
|
||||||
|
Duration validFor = const Duration(hours: 1),
|
||||||
|
}) => FirebaseCredentials(
|
||||||
|
idToken: idToken,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
expiresAt: _now.add(validFor),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('idToken', () {
|
||||||
|
test('fails loudly when no session is stored', () {
|
||||||
|
// Never returns null or a stale token: a sync that quietly stops
|
||||||
|
// syncing is exactly the failure mode being designed out.
|
||||||
|
expect(
|
||||||
|
_provider([]).idToken,
|
||||||
|
throwsA(
|
||||||
|
isA<FirebaseAuthError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('not signed in'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the stored token without a network call', () async {
|
||||||
|
// An empty response list means any HTTP call would throw a range error.
|
||||||
|
final provider = _provider(
|
||||||
|
[],
|
||||||
|
store: InMemoryCredentialStore(_credentials()),
|
||||||
|
);
|
||||||
|
expect(await provider.idToken(), 'id-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'refreshes an expired token and persists the rotated refresh token',
|
||||||
|
() async {
|
||||||
|
final store = InMemoryCredentialStore(
|
||||||
|
_credentials(validFor: const Duration(minutes: -5)),
|
||||||
|
);
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'id_token': 'id-2',
|
||||||
|
'refresh_token': 'refresh-2',
|
||||||
|
'expires_in': '3600',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
expect(await provider.idToken(), 'id-2');
|
||||||
|
final saved = await store.load();
|
||||||
|
expect(saved!.refreshToken, 'refresh-2');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('fails loudly when the refresh token has been revoked', () async {
|
||||||
|
final provider = _provider(
|
||||||
|
[
|
||||||
|
_json(400, {
|
||||||
|
'error': {'message': 'TOKEN_EXPIRED'},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
store: InMemoryCredentialStore(
|
||||||
|
_credentials(validFor: const Duration(minutes: -5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
provider.idToken,
|
||||||
|
throwsA(
|
||||||
|
isA<FirebaseAuthError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('TOKEN_EXPIRED'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('session lifecycle', () {
|
||||||
|
test('hasSession is false before sign-in and true after', () async {
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-1',
|
||||||
|
'refreshToken': 'refresh-1',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
expect(await provider.hasSession(), isFalse);
|
||||||
|
await provider.signIn(email: 'a@b.c', password: 'x');
|
||||||
|
expect(await provider.hasSession(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signOut clears the store so the next idToken fails', () async {
|
||||||
|
final provider = _provider(
|
||||||
|
[],
|
||||||
|
store: InMemoryCredentialStore(_credentials()),
|
||||||
|
);
|
||||||
|
expect(await provider.idToken(), 'id-1');
|
||||||
|
await provider.signOut();
|
||||||
|
expect(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close releases the HTTP client', () {
|
||||||
|
expect(_provider([]).close, returnsNormally);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('a refresh token the server rejects', () {
|
||||||
|
// Regression: hasSession() was a presence check, so a revoked token still
|
||||||
|
// read as "Connected" while every sync died with TOKEN_EXPIRED. Observed
|
||||||
|
// on the desktop todo build on 2026-08-11.
|
||||||
|
test(
|
||||||
|
'is cleared, so hasSession stops claiming the device is connected',
|
||||||
|
() async {
|
||||||
|
final store = InMemoryCredentialStore(
|
||||||
|
_credentials(validFor: const Duration(seconds: -1)),
|
||||||
|
);
|
||||||
|
final provider = _provider([
|
||||||
|
_json(400, {
|
||||||
|
'error': {'message': 'TOKEN_EXPIRED'},
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
||||||
|
expect(
|
||||||
|
await provider.hasSession(),
|
||||||
|
isFalse,
|
||||||
|
reason: 'a revoked session must not keep reporting as connected',
|
||||||
|
);
|
||||||
|
expect(await store.load(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('survives a network error, which is transient', () async {
|
||||||
|
// The opposite failure: signing out whenever the wifi drops would need a
|
||||||
|
// manual sign-in on every device to recover.
|
||||||
|
final store = InMemoryCredentialStore(
|
||||||
|
_credentials(validFor: const Duration(seconds: -1)),
|
||||||
|
);
|
||||||
|
final provider = _provider([
|
||||||
|
http.ClientException('connection reset'),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
||||||
|
expect(
|
||||||
|
await provider.hasSession(),
|
||||||
|
isTrue,
|
||||||
|
reason: 'a transient failure must not discard a good refresh token',
|
||||||
|
);
|
||||||
|
expect(await store.load(), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a 5xx, which is also transient', () async {
|
||||||
|
final store = InMemoryCredentialStore(
|
||||||
|
_credentials(validFor: const Duration(seconds: -1)),
|
||||||
|
);
|
||||||
|
final provider = _provider([
|
||||||
|
_json(503, {
|
||||||
|
'error': {'message': 'SERVICE_UNAVAILABLE'},
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
|
||||||
|
expect(await provider.hasSession(), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
134
crdt_sync_dart/test/firebase_auth_rest_uid_test.dart
Normal file
134
crdt_sync_dart/test/firebase_auth_rest_uid_test.dart
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A fixed "now" so token expiry is deterministic rather than wall-clock.
|
||||||
|
final _now = DateTime.utc(2026, 8, 3, 12);
|
||||||
|
|
||||||
|
http.Response _json(int statusCode, Object body) =>
|
||||||
|
http.Response(jsonEncode(body), statusCode);
|
||||||
|
|
||||||
|
/// Builds a provider whose POSTs return [responses] in call order (each
|
||||||
|
/// either an [http.Response] or a thrown [http.ClientException]).
|
||||||
|
FirebaseTokenProvider _provider(
|
||||||
|
List<Object> responses, {
|
||||||
|
FirebaseCredentialStore? store,
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
var index = 0;
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
final response = responses[index];
|
||||||
|
index++;
|
||||||
|
if (response is http.ClientException) throw response;
|
||||||
|
return response as http.Response;
|
||||||
|
});
|
||||||
|
return FirebaseTokenProvider(
|
||||||
|
apiKey: 'fake-api-key',
|
||||||
|
store: store ?? InMemoryCredentialStore(),
|
||||||
|
httpClient: mock,
|
||||||
|
clock: () => now ?? _now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('signInWithGoogle uid verification', () {
|
||||||
|
test('rejects a session for a uid other than the expected one', () async {
|
||||||
|
// The mis-tapped-account-picker case. signInWithIdp signs in OR SIGNS
|
||||||
|
// UP, so an unlinked Google identity is accepted as a brand-new user
|
||||||
|
// rather than refused. Without this check the device would store that
|
||||||
|
// session and then be denied every read and write, permanently.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'localId': 'some-other-uid',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
() => provider.signInWithGoogle(
|
||||||
|
idToken: 'token-for-the-wrong-account',
|
||||||
|
expectedUid: 'the-right-uid',
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<FirebaseAuthError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
allOf(contains('some-other-uid'), contains('the-right-uid')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await store.load(),
|
||||||
|
isNull,
|
||||||
|
reason: 'a wrong-account session must not persist',
|
||||||
|
);
|
||||||
|
expect(await provider.hasSession(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a session whose uid matches the expected one', () async {
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'localId': 'the-right-uid',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await provider.signInWithGoogle(
|
||||||
|
idToken: 'token',
|
||||||
|
expectedUid: 'the-right-uid',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await store.load())!.idToken, 'id-g');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips the uid check when no uid is expected', () async {
|
||||||
|
// FirebaseProject.configFor leaves uid empty on the app path; comparing
|
||||||
|
// against '' would otherwise fail every sign-in.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'localId': 'whatever-uid',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await provider.signInWithGoogle(idToken: 'token', expectedUid: '');
|
||||||
|
|
||||||
|
expect((await store.load())!.idToken, 'id-g');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('its session refreshes like a password session', () async {
|
||||||
|
// signInWithIdp returns the same refresh-token shape as
|
||||||
|
// signInWithPassword, which is why the refresh path needed no changes.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final provider = _provider([
|
||||||
|
_json(200, {
|
||||||
|
'idToken': 'id-g',
|
||||||
|
'refreshToken': 'refresh-g',
|
||||||
|
// Already inside the refresh skew, so the next read must refresh.
|
||||||
|
'expiresIn': '60',
|
||||||
|
}),
|
||||||
|
_json(200, {
|
||||||
|
'id_token': 'id-g2',
|
||||||
|
'refresh_token': 'refresh-g2',
|
||||||
|
'expires_in': '3600',
|
||||||
|
}),
|
||||||
|
], store: store);
|
||||||
|
|
||||||
|
await provider.signInWithGoogle(idToken: 'google-token');
|
||||||
|
expect(await provider.idToken(), 'id-g2');
|
||||||
|
expect((await store.load())!.refreshToken, 'refresh-g2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,7 +5,8 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:http/testing.dart' as http_testing;
|
import 'package:http/testing.dart' as http_testing;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
const _databaseUrl = 'https://example-default-rtdb.europe-west1.firebasedatabase.app';
|
const _databaseUrl =
|
||||||
|
'https://example-default-rtdb.europe-west1.firebasedatabase.app';
|
||||||
|
|
||||||
http.Response _json(int statusCode, Object? body) =>
|
http.Response _json(int statusCode, Object? body) =>
|
||||||
http.Response(jsonEncode(body), statusCode);
|
http.Response(jsonEncode(body), statusCode);
|
||||||
@ -31,10 +32,7 @@ FirebaseTokenProvider _auth() => FirebaseTokenProvider(
|
|||||||
/// decoded result.
|
/// decoded result.
|
||||||
late List<http.Request> requests;
|
late List<http.Request> requests;
|
||||||
|
|
||||||
FirebaseRestClient _client(
|
FirebaseRestClient _client(Object response, {FirebaseTokenProvider? auth}) {
|
||||||
Object response, {
|
|
||||||
FirebaseTokenProvider? auth,
|
|
||||||
}) {
|
|
||||||
requests = [];
|
requests = [];
|
||||||
final mock = http_testing.MockClient((request) async {
|
final mock = http_testing.MockClient((request) async {
|
||||||
requests.add(request);
|
requests.add(request);
|
||||||
@ -58,8 +56,10 @@ void main() {
|
|||||||
|
|
||||||
test('leaves dot-free device ids untouched', () {
|
test('leaves dot-free device ids untouched', () {
|
||||||
expect(encodeKey('pc'), 'pc');
|
expect(encodeKey('pc'), 'pc');
|
||||||
expect(encodeKey('32c28cf3-c0eb-423f-8bac-9bda9f158054'),
|
expect(
|
||||||
'32c28cf3-c0eb-423f-8bac-9bda9f158054');
|
encodeKey('32c28cf3-c0eb-423f-8bac-9bda9f158054'),
|
||||||
|
'32c28cf3-c0eb-423f-8bac-9bda9f158054',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('escapes every character RTDB forbids, plus the escape char', () {
|
test('escapes every character RTDB forbids, plus the escape char', () {
|
||||||
@ -103,10 +103,7 @@ void main() {
|
|||||||
test('requests the escaped path with the auth token', () async {
|
test('requests the escaped path with the auth token', () async {
|
||||||
final client = _client(_json(200, '{}'));
|
final client = _client(_json(200, '{}'));
|
||||||
await client.getFileText('ns/devices/pc/log.json');
|
await client.getFileText('ns/devices/pc/log.json');
|
||||||
expect(
|
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
|
||||||
requests.single.url.path,
|
|
||||||
'/ns/devices/pc/log~2Ejson.json',
|
|
||||||
);
|
|
||||||
expect(requests.single.url.queryParameters['auth'], 'id-token');
|
expect(requests.single.url.queryParameters['auth'], 'id-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -125,13 +122,16 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('raises DatabaseNotFoundError when the rules reject the uid', () async {
|
test(
|
||||||
final client = _client(_json(401, {'error': 'Permission denied'}));
|
'raises DatabaseNotFoundError when the rules reject the uid',
|
||||||
expect(
|
() async {
|
||||||
() => client.getFileText('ns/devices/pc/log.json'),
|
final client = _client(_json(401, {'error': 'Permission denied'}));
|
||||||
throwsA(isA<DatabaseNotFoundError>()),
|
expect(
|
||||||
);
|
() => client.getFileText('ns/devices/pc/log.json'),
|
||||||
});
|
throwsA(isA<DatabaseNotFoundError>()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('raises DatabaseNotFoundError on 403 too', () async {
|
test('raises DatabaseNotFoundError on 403 too', () async {
|
||||||
final client = _client(_json(403, {'error': 'Permission denied'}));
|
final client = _client(_json(403, {'error': 'Permission denied'}));
|
||||||
@ -195,180 +195,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('putFileText', () {
|
|
||||||
test('writes the text as a JSON string leaf', () async {
|
|
||||||
final client = _client(_json(200, '{"a":1}'));
|
|
||||||
await client.putFileText(
|
|
||||||
'ns/devices/pc/log.json',
|
|
||||||
'{"a":1}',
|
|
||||||
message: 'ignored by rtdb',
|
|
||||||
);
|
|
||||||
expect(requests.single.method, 'PUT');
|
|
||||||
expect(requests.single.body, jsonEncode('{"a":1}'));
|
|
||||||
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('raises on a non-2xx response', () async {
|
|
||||||
final client = _client(_json(400, {'error': 'bad'}));
|
|
||||||
expect(
|
|
||||||
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('turns a network failure into FirebaseSyncError', () async {
|
|
||||||
final client = _client(http.ClientException('offline'));
|
|
||||||
expect(
|
|
||||||
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('patchValues', () {
|
|
||||||
test('PATCHes rather than PUTs, so sibling keys survive', () async {
|
|
||||||
// A PUT on the shared revs map would wipe every other device's entry,
|
|
||||||
// after which those devices would look permanently unchanged and never
|
|
||||||
// be fetched again.
|
|
||||||
final client = _client(_json(200, {'pc': 'abc'}));
|
|
||||||
await client.patchValues('ns/revs', {'pc': 'abc'});
|
|
||||||
expect(requests.single.method, 'PATCH');
|
|
||||||
expect(requests.single.body, jsonEncode({'pc': 'abc'}));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('raises on a non-2xx response', () async {
|
|
||||||
final client = _client(_json(500, {'error': 'boom'}));
|
|
||||||
expect(
|
|
||||||
() => client.patchValues('ns/revs', {'pc': 'abc'}),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('turns a network failure into FirebaseSyncError', () async {
|
|
||||||
final client = _client(http.ClientException('offline'));
|
|
||||||
expect(
|
|
||||||
() => client.patchValues('ns/revs', {'pc': 'abc'}),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('getStringMap', () {
|
|
||||||
test('returns the map with keys decoded', () async {
|
|
||||||
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 'sha-2'}));
|
|
||||||
expect(await client.getStringMap('ns/revs'), {
|
|
||||||
'pc': 'sha-1',
|
|
||||||
'phone': 'sha-2',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('degrades to empty when absent', () async {
|
|
||||||
final client = _client(_json(200, null));
|
|
||||||
expect(await client.getStringMap('ns/revs'), isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips non-string entries rather than failing the sync', () async {
|
|
||||||
// The revs node is an optimisation; a corrupt one must degrade into
|
|
||||||
// "fetch everything", never into a failed tick.
|
|
||||||
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 42}));
|
|
||||||
expect(await client.getStringMap('ns/revs'), {'pc': 'sha-1'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('raises on a non-2xx response', () async {
|
|
||||||
final client = _client(_json(500, {'error': 'boom'}));
|
|
||||||
expect(
|
|
||||||
() => client.getStringMap('ns/revs'),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('deleteFile', () {
|
|
||||||
test('sends a DELETE to the escaped path', () async {
|
|
||||||
final client = _client(_json(200, null));
|
|
||||||
await client.deleteFile('ns/devices/pc/log.json');
|
|
||||||
expect(requests.single.method, 'DELETE');
|
|
||||||
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('raises on a non-2xx response', () async {
|
|
||||||
final client = _client(_json(500, {'error': 'boom'}));
|
|
||||||
expect(
|
|
||||||
() => client.deleteFile('ns/devices/pc/log.json'),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('turns a network failure into FirebaseSyncError', () async {
|
|
||||||
final client = _client(http.ClientException('offline'));
|
|
||||||
expect(
|
|
||||||
() => client.deleteFile('ns/devices/pc/log.json'),
|
|
||||||
throwsA(isA<FirebaseSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('canAccessRemote', () {
|
|
||||||
test('is true when the database root reads', () async {
|
|
||||||
expect(await _client(_json(200, {'ns': true})).canAccessRemote(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false when the rules reject the token', () async {
|
|
||||||
expect(await _client(_json(401, {'error': 'denied'})).canAccessRemote(),
|
|
||||||
isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false on a network error', () async {
|
|
||||||
expect(
|
|
||||||
await _client(http.ClientException('offline')).canAccessRemote(),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false when no session is stored, rather than throwing', () async {
|
|
||||||
// "cannot get a token" is exactly "cannot access the remote", and a
|
|
||||||
// settings screen's Test-connection button must not blow up.
|
|
||||||
final client = _client(
|
|
||||||
_json(200, {}),
|
|
||||||
auth: FirebaseTokenProvider(
|
|
||||||
apiKey: 'k',
|
|
||||||
store: InMemoryCredentialStore(),
|
|
||||||
httpClient: http_testing.MockClient((_) async => _json(200, {})),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(await client.canAccessRemote(), isFalse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('RemoteStore contract', () {
|
|
||||||
test('FirebaseRestClient is a RemoteStore', () {
|
|
||||||
expect(_client(_json(200, null)), isA<RemoteStore>());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DatabaseNotFoundError is catchable as either error type', () {
|
|
||||||
final error = DatabaseNotFoundError('gone');
|
|
||||||
expect(error, isA<FirebaseSyncError>());
|
|
||||||
expect(error, isA<RemoteNotFoundError>());
|
|
||||||
expect(error, isA<RemoteSyncError>());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a trailing slash on the database URL is tolerated', () async {
|
|
||||||
final client = FirebaseRestClient(
|
|
||||||
databaseUrl: '$_databaseUrl/',
|
|
||||||
auth: _auth(),
|
|
||||||
httpClient: http_testing.MockClient((request) async {
|
|
||||||
requests.add(request);
|
|
||||||
return _json(200, 'ok');
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
requests = [];
|
|
||||||
await client.getFileText('ns/x.json');
|
|
||||||
expect(requests.single.url.toString(), startsWith('$_databaseUrl/ns/'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('close releases the HTTP client', () {
|
|
||||||
expect(_client(_json(200, null)).close, returnsNormally);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
227
crdt_sync_dart/test/firebase_client_writes_test.dart
Normal file
227
crdt_sync_dart/test/firebase_client_writes_test.dart
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _databaseUrl =
|
||||||
|
'https://example-default-rtdb.europe-west1.firebasedatabase.app';
|
||||||
|
|
||||||
|
http.Response _json(int statusCode, Object? body) =>
|
||||||
|
http.Response(jsonEncode(body), statusCode);
|
||||||
|
|
||||||
|
/// A provider holding a long-lived token, so client tests never hit the auth
|
||||||
|
/// endpoints -- an HTTP call from the token provider would fail loudly here.
|
||||||
|
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
|
||||||
|
apiKey: 'fake-api-key',
|
||||||
|
store: InMemoryCredentialStore(
|
||||||
|
FirebaseCredentials(
|
||||||
|
idToken: 'id-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresAt: DateTime.utc(2099),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
httpClient: http_testing.MockClient(
|
||||||
|
(_) async => throw StateError('auth must not be contacted'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Records every request the client makes, so tests can assert on the URL
|
||||||
|
/// (which carries the key escaping and the shallow flag) as well as the
|
||||||
|
/// decoded result.
|
||||||
|
late List<http.Request> requests;
|
||||||
|
|
||||||
|
FirebaseRestClient _client(Object response, {FirebaseTokenProvider? auth}) {
|
||||||
|
requests = [];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
if (response is http.ClientException) throw response;
|
||||||
|
return response as http.Response;
|
||||||
|
});
|
||||||
|
return FirebaseRestClient(
|
||||||
|
databaseUrl: _databaseUrl,
|
||||||
|
auth: auth ?? _auth(),
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('putFileText', () {
|
||||||
|
test('writes the text as a JSON string leaf', () async {
|
||||||
|
final client = _client(_json(200, '{"a":1}'));
|
||||||
|
await client.putFileText(
|
||||||
|
'ns/devices/pc/log.json',
|
||||||
|
'{"a":1}',
|
||||||
|
message: 'ignored by rtdb',
|
||||||
|
);
|
||||||
|
expect(requests.single.method, 'PUT');
|
||||||
|
expect(requests.single.body, jsonEncode('{"a":1}'));
|
||||||
|
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raises on a non-2xx response', () async {
|
||||||
|
final client = _client(_json(400, {'error': 'bad'}));
|
||||||
|
expect(
|
||||||
|
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turns a network failure into FirebaseSyncError', () async {
|
||||||
|
final client = _client(http.ClientException('offline'));
|
||||||
|
expect(
|
||||||
|
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('patchValues', () {
|
||||||
|
test('PATCHes rather than PUTs, so sibling keys survive', () async {
|
||||||
|
// A PUT on the shared revs map would wipe every other device's entry,
|
||||||
|
// after which those devices would look permanently unchanged and never
|
||||||
|
// be fetched again.
|
||||||
|
final client = _client(_json(200, {'pc': 'abc'}));
|
||||||
|
await client.patchValues('ns/revs', {'pc': 'abc'});
|
||||||
|
expect(requests.single.method, 'PATCH');
|
||||||
|
expect(requests.single.body, jsonEncode({'pc': 'abc'}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raises on a non-2xx response', () async {
|
||||||
|
final client = _client(_json(500, {'error': 'boom'}));
|
||||||
|
expect(
|
||||||
|
() => client.patchValues('ns/revs', {'pc': 'abc'}),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turns a network failure into FirebaseSyncError', () async {
|
||||||
|
final client = _client(http.ClientException('offline'));
|
||||||
|
expect(
|
||||||
|
() => client.patchValues('ns/revs', {'pc': 'abc'}),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getStringMap', () {
|
||||||
|
test('returns the map with keys decoded', () async {
|
||||||
|
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 'sha-2'}));
|
||||||
|
expect(await client.getStringMap('ns/revs'), {
|
||||||
|
'pc': 'sha-1',
|
||||||
|
'phone': 'sha-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degrades to empty when absent', () async {
|
||||||
|
final client = _client(_json(200, null));
|
||||||
|
expect(await client.getStringMap('ns/revs'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips non-string entries rather than failing the sync', () async {
|
||||||
|
// The revs node is an optimisation; a corrupt one must degrade into
|
||||||
|
// "fetch everything", never into a failed tick.
|
||||||
|
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 42}));
|
||||||
|
expect(await client.getStringMap('ns/revs'), {'pc': 'sha-1'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raises on a non-2xx response', () async {
|
||||||
|
final client = _client(_json(500, {'error': 'boom'}));
|
||||||
|
expect(
|
||||||
|
() => client.getStringMap('ns/revs'),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('deleteFile', () {
|
||||||
|
test('sends a DELETE to the escaped path', () async {
|
||||||
|
final client = _client(_json(200, null));
|
||||||
|
await client.deleteFile('ns/devices/pc/log.json');
|
||||||
|
expect(requests.single.method, 'DELETE');
|
||||||
|
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raises on a non-2xx response', () async {
|
||||||
|
final client = _client(_json(500, {'error': 'boom'}));
|
||||||
|
expect(
|
||||||
|
() => client.deleteFile('ns/devices/pc/log.json'),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turns a network failure into FirebaseSyncError', () async {
|
||||||
|
final client = _client(http.ClientException('offline'));
|
||||||
|
expect(
|
||||||
|
() => client.deleteFile('ns/devices/pc/log.json'),
|
||||||
|
throwsA(isA<FirebaseSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('canAccessRemote', () {
|
||||||
|
test('is true when the database root reads', () async {
|
||||||
|
expect(await _client(_json(200, {'ns': true})).canAccessRemote(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false when the rules reject the token', () async {
|
||||||
|
expect(
|
||||||
|
await _client(_json(401, {'error': 'denied'})).canAccessRemote(),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false on a network error', () async {
|
||||||
|
expect(
|
||||||
|
await _client(http.ClientException('offline')).canAccessRemote(),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false when no session is stored, rather than throwing', () async {
|
||||||
|
// "cannot get a token" is exactly "cannot access the remote", and a
|
||||||
|
// settings screen's Test-connection button must not blow up.
|
||||||
|
final client = _client(
|
||||||
|
_json(200, {}),
|
||||||
|
auth: FirebaseTokenProvider(
|
||||||
|
apiKey: 'k',
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
httpClient: http_testing.MockClient((_) async => _json(200, {})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await client.canAccessRemote(), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RemoteStore contract', () {
|
||||||
|
test('FirebaseRestClient is a RemoteStore', () {
|
||||||
|
expect(_client(_json(200, null)), isA<RemoteStore>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DatabaseNotFoundError is catchable as either error type', () {
|
||||||
|
final error = DatabaseNotFoundError('gone');
|
||||||
|
expect(error, isA<FirebaseSyncError>());
|
||||||
|
expect(error, isA<RemoteNotFoundError>());
|
||||||
|
expect(error, isA<RemoteSyncError>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a trailing slash on the database URL is tolerated', () async {
|
||||||
|
final client = FirebaseRestClient(
|
||||||
|
databaseUrl: '$_databaseUrl/',
|
||||||
|
auth: _auth(),
|
||||||
|
httpClient: http_testing.MockClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
return _json(200, 'ok');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
requests = [];
|
||||||
|
await client.getFileText('ns/x.json');
|
||||||
|
expect(requests.single.url.toString(), startsWith('$_databaseUrl/ns/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close releases the HTTP client', () {
|
||||||
|
expect(_client(_json(200, null)).close, returnsNormally);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
164
crdt_sync_dart/test/firebase_config_client_google_test.dart
Normal file
164
crdt_sync_dart/test/firebase_config_client_google_test.dart
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/// Tests for the shared Firebase configuration on the Dart side.
|
||||||
|
///
|
||||||
|
/// The failure modes asserted here are the ones that would otherwise surface
|
||||||
|
/// as an authentication error long after the real mistake, so each is checked
|
||||||
|
/// to name the field at fault rather than merely to throw.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _valid = {
|
||||||
|
'apiKey': 'AIzaSyExample',
|
||||||
|
'databaseUrl':
|
||||||
|
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
|
||||||
|
'projectId': 'kuhy-syncs',
|
||||||
|
'uid': 'OvA2REQyLIhAHOEjzwS1o877rgG3',
|
||||||
|
'email': 'sync@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A [RemoteStore] that does nothing, standing in for the GitHub client.
|
||||||
|
class _StubRemote implements RemoteStore {
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('client construction with Google', () {
|
||||||
|
test('refuses a Google account that is not the configured uid', () async {
|
||||||
|
// End of the chain the phone actually walks: a mis-tapped account in the
|
||||||
|
// picker must fail here rather than storing a session that the security
|
||||||
|
// rules then deny on every read and write.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final mock = http_testing.MockClient(
|
||||||
|
(request) async => http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'idToken': 'id',
|
||||||
|
'refreshToken': 'refresh',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'localId': 'a-different-persons-uid',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: store,
|
||||||
|
googleIdToken: () async => 'token-for-the-wrong-account',
|
||||||
|
httpClient: mock,
|
||||||
|
),
|
||||||
|
throwsA(isA<FirebaseAuthError>()),
|
||||||
|
);
|
||||||
|
expect(await store.load(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the password when Google returns null', () async {
|
||||||
|
// Desktop and headless: the provider is wired but nobody is signed in
|
||||||
|
// with Google, so the machine credential has to still work.
|
||||||
|
final sentTo = <String>[];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
sentTo.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'idToken': 'id',
|
||||||
|
'refreshToken': 'refresh',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = await firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
password: 'hunter2',
|
||||||
|
googleIdToken: () async => null,
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sentTo.single, contains('signInWithPassword'));
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to build when Google returns null and no password', () async {
|
||||||
|
await expectLater(
|
||||||
|
firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
googleIdToken: () async => null,
|
||||||
|
),
|
||||||
|
throwsA(isA<ConfigException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses a stored session without a password', () async {
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
await store.save(
|
||||||
|
FirebaseCredentials(
|
||||||
|
idToken: 'id',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final client = await firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: store,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client, isA<FirebaseRestClient>());
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'mirrorStoreFor makes Firebase primary and GitHub the mirror',
|
||||||
|
() async {
|
||||||
|
// Which backend is authoritative is the one thing a rollback depends on.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
await store.save(
|
||||||
|
FirebaseCredentials(
|
||||||
|
idToken: 'id',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final github = _StubRemote();
|
||||||
|
|
||||||
|
final mirror = await mirrorStoreFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: store,
|
||||||
|
githubClient: github,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mirror.primary, isA<FirebaseRestClient>());
|
||||||
|
expect(mirror.mirror, same(github));
|
||||||
|
mirror.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
127
crdt_sync_dart/test/firebase_config_client_test.dart
Normal file
127
crdt_sync_dart/test/firebase_config_client_test.dart
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/// Tests for the shared Firebase configuration on the Dart side.
|
||||||
|
///
|
||||||
|
/// The failure modes asserted here are the ones that would otherwise surface
|
||||||
|
/// as an authentication error long after the real mistake, so each is checked
|
||||||
|
/// to name the field at fault rather than merely to throw.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _valid = {
|
||||||
|
'apiKey': 'AIzaSyExample',
|
||||||
|
'databaseUrl':
|
||||||
|
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
|
||||||
|
'projectId': 'kuhy-syncs',
|
||||||
|
'uid': 'OvA2REQyLIhAHOEjzwS1o877rgG3',
|
||||||
|
'email': 'sync@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('client construction', () {
|
||||||
|
test('refuses to build without a session or a password', () async {
|
||||||
|
// Better than signing in with an empty password and failing remotely.
|
||||||
|
await expectLater(
|
||||||
|
firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
),
|
||||||
|
throwsA(isA<ConfigException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signs in with the password when there is no session', () async {
|
||||||
|
// The first-run path: no cached refresh token, so it must exchange the
|
||||||
|
// password for one rather than failing.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final sentTo = <String>[];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
sentTo.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'idToken': 'id',
|
||||||
|
'refreshToken': 'refresh',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'localId': _valid['uid'],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = await firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: store,
|
||||||
|
password: 'hunter2',
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sentTo.single, contains('signInWithPassword'));
|
||||||
|
expect(await store.load(), isNotNull, reason: 'session must persist');
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signs in with Google when a token is available', () async {
|
||||||
|
// The one-tap path: a fresh install with no password typed anywhere.
|
||||||
|
final store = InMemoryCredentialStore();
|
||||||
|
final sentTo = <String>[];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
sentTo.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'idToken': 'id',
|
||||||
|
'refreshToken': 'refresh',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
'localId': _valid['uid'],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = await firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: store,
|
||||||
|
googleIdToken: () async => 'google-token',
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sentTo.single, contains('signInWithIdp'));
|
||||||
|
expect(await store.load(), isNotNull, reason: 'session must persist');
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers Google over the password when both are available', () async {
|
||||||
|
// Google is the credential the user did not have to type, so on a device
|
||||||
|
// offering both it should win.
|
||||||
|
final sentTo = <String>[];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
sentTo.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'idToken': 'id',
|
||||||
|
'refreshToken': 'refresh',
|
||||||
|
'expiresIn': '3600',
|
||||||
|
// Firebase always returns localId; the config carries a uid, so
|
||||||
|
// omitting it here would trip the wrong-account guard.
|
||||||
|
'localId': _valid['uid'],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = await firebaseClientFor(
|
||||||
|
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
password: 'hunter2',
|
||||||
|
googleIdToken: () async => 'google-token',
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sentTo.single, contains('signInWithIdp'));
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -8,8 +8,6 @@ library;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:http/testing.dart' as http_testing;
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
const _valid = {
|
const _valid = {
|
||||||
@ -21,31 +19,6 @@ const _valid = {
|
|||||||
'email': 'sync@example.com',
|
'email': 'sync@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A [RemoteStore] that does nothing, standing in for the GitHub client.
|
|
||||||
class _StubRemote implements RemoteStore {
|
|
||||||
@override
|
|
||||||
Future<List<String>> listDirectory(String path) async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> getFileText(String path) async => null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> putFileText(
|
|
||||||
String path,
|
|
||||||
String text, {
|
|
||||||
required String message,
|
|
||||||
}) async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteFile(String path, {String message = ''}) async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> canAccessRemote() async => true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void close() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('FirebaseConfig', () {
|
group('FirebaseConfig', () {
|
||||||
test('parses a valid config', () {
|
test('parses a valid config', () {
|
||||||
@ -141,220 +114,4 @@ void main() {
|
|||||||
expect(ConfigException('boom').toString(), contains('boom'));
|
expect(ConfigException('boom').toString(), contains('boom'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('client construction', () {
|
|
||||||
test('refuses to build without a session or a password', () async {
|
|
||||||
// Better than signing in with an empty password and failing remotely.
|
|
||||||
await expectLater(
|
|
||||||
firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: InMemoryCredentialStore(),
|
|
||||||
),
|
|
||||||
throwsA(isA<ConfigException>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('signs in with the password when there is no session', () async {
|
|
||||||
// The first-run path: no cached refresh token, so it must exchange the
|
|
||||||
// password for one rather than failing.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final sentTo = <String>[];
|
|
||||||
final mock = http_testing.MockClient((request) async {
|
|
||||||
sentTo.add(request.url.path);
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'idToken': 'id',
|
|
||||||
'refreshToken': 'refresh',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'localId': _valid['uid'],
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final client = await firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: store,
|
|
||||||
password: 'hunter2',
|
|
||||||
httpClient: mock,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sentTo.single, contains('signInWithPassword'));
|
|
||||||
expect(await store.load(), isNotNull, reason: 'session must persist');
|
|
||||||
client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('signs in with Google when a token is available', () async {
|
|
||||||
// The one-tap path: a fresh install with no password typed anywhere.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final sentTo = <String>[];
|
|
||||||
final mock = http_testing.MockClient((request) async {
|
|
||||||
sentTo.add(request.url.path);
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'idToken': 'id',
|
|
||||||
'refreshToken': 'refresh',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'localId': _valid['uid'],
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final client = await firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: store,
|
|
||||||
googleIdToken: () async => 'google-token',
|
|
||||||
httpClient: mock,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sentTo.single, contains('signInWithIdp'));
|
|
||||||
expect(await store.load(), isNotNull, reason: 'session must persist');
|
|
||||||
client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefers Google over the password when both are available', () async {
|
|
||||||
// Google is the credential the user did not have to type, so on a device
|
|
||||||
// offering both it should win.
|
|
||||||
final sentTo = <String>[];
|
|
||||||
final mock = http_testing.MockClient((request) async {
|
|
||||||
sentTo.add(request.url.path);
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'idToken': 'id',
|
|
||||||
'refreshToken': 'refresh',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
// Firebase always returns localId; the config carries a uid, so
|
|
||||||
// omitting it here would trip the wrong-account guard.
|
|
||||||
'localId': _valid['uid'],
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final client = await firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: InMemoryCredentialStore(),
|
|
||||||
password: 'hunter2',
|
|
||||||
googleIdToken: () async => 'google-token',
|
|
||||||
httpClient: mock,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sentTo.single, contains('signInWithIdp'));
|
|
||||||
client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refuses a Google account that is not the configured uid', () async {
|
|
||||||
// End of the chain the phone actually walks: a mis-tapped account in the
|
|
||||||
// picker must fail here rather than storing a session that the security
|
|
||||||
// rules then deny on every read and write.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
final mock = http_testing.MockClient(
|
|
||||||
(request) async => http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'idToken': 'id',
|
|
||||||
'refreshToken': 'refresh',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
'localId': 'a-different-persons-uid',
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: store,
|
|
||||||
googleIdToken: () async => 'token-for-the-wrong-account',
|
|
||||||
httpClient: mock,
|
|
||||||
),
|
|
||||||
throwsA(isA<FirebaseAuthError>()),
|
|
||||||
);
|
|
||||||
expect(await store.load(), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to the password when Google returns null', () async {
|
|
||||||
// Desktop and headless: the provider is wired but nobody is signed in
|
|
||||||
// with Google, so the machine credential has to still work.
|
|
||||||
final sentTo = <String>[];
|
|
||||||
final mock = http_testing.MockClient((request) async {
|
|
||||||
sentTo.add(request.url.path);
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'idToken': 'id',
|
|
||||||
'refreshToken': 'refresh',
|
|
||||||
'expiresIn': '3600',
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final client = await firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: InMemoryCredentialStore(),
|
|
||||||
password: 'hunter2',
|
|
||||||
googleIdToken: () async => null,
|
|
||||||
httpClient: mock,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sentTo.single, contains('signInWithPassword'));
|
|
||||||
client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refuses to build when Google returns null and no password', () async {
|
|
||||||
await expectLater(
|
|
||||||
firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: InMemoryCredentialStore(),
|
|
||||||
googleIdToken: () async => null,
|
|
||||||
),
|
|
||||||
throwsA(isA<ConfigException>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reuses a stored session without a password', () async {
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
await store.save(
|
|
||||||
FirebaseCredentials(
|
|
||||||
idToken: 'id',
|
|
||||||
refreshToken: 'refresh',
|
|
||||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = await firebaseClientFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: store,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client, isA<FirebaseRestClient>());
|
|
||||||
client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'mirrorStoreFor makes Firebase primary and GitHub the mirror',
|
|
||||||
() async {
|
|
||||||
// Which backend is authoritative is the one thing a rollback depends on.
|
|
||||||
final store = InMemoryCredentialStore();
|
|
||||||
await store.save(
|
|
||||||
FirebaseCredentials(
|
|
||||||
idToken: 'id',
|
|
||||||
refreshToken: 'refresh',
|
|
||||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final github = _StubRemote();
|
|
||||||
|
|
||||||
final mirror = await mirrorStoreFor(
|
|
||||||
config: FirebaseConfig.parse(jsonEncode(_valid)),
|
|
||||||
store: store,
|
|
||||||
githubClient: github,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mirror.primary, isA<FirebaseRestClient>());
|
|
||||||
expect(mirror.mirror, same(github));
|
|
||||||
mirror.close();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,152 +140,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('putFileText', () {
|
|
||||||
test('creates a new file with no sha when none existed', () async {
|
|
||||||
final client = _client([
|
|
||||||
_response(404),
|
|
||||||
_response(200),
|
|
||||||
], putResponse: _response(201));
|
|
||||||
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updates an existing file by including its sha', () async {
|
|
||||||
final client = _client([
|
|
||||||
_response(200, {'sha': 'abc123'}),
|
|
||||||
], putResponse: _response(200));
|
|
||||||
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'throws RepoNotFoundError when checking sha on a missing repo',
|
|
||||||
() async {
|
|
||||||
final client = _client([_response(404), _response(404)]);
|
|
||||||
expect(
|
|
||||||
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<RepoNotFoundError>()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('throws GitHubSyncError when the sha check itself fails', () async {
|
|
||||||
final client = _client([_response(500)]);
|
|
||||||
expect(
|
|
||||||
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<GitHubSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws GitHubSyncError on a put network exception', () async {
|
|
||||||
final client = _client([
|
|
||||||
_response(404),
|
|
||||||
_response(200),
|
|
||||||
], putError: http.ClientException('offline'));
|
|
||||||
expect(
|
|
||||||
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<GitHubSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws GitHubSyncError on a put non-2xx response', () async {
|
|
||||||
final client = _client([
|
|
||||||
_response(404),
|
|
||||||
_response(200),
|
|
||||||
], putResponse: _response(422));
|
|
||||||
expect(
|
|
||||||
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<GitHubSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('canAccessRepo', () {
|
|
||||||
test('is true when the repo endpoint returns 2xx', () async {
|
|
||||||
expect(await _client([_response(200)]).canAccessRepo(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false when the repo is missing (404)', () async {
|
|
||||||
expect(await _client([_response(404)]).canAccessRepo(), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false on a network error', () async {
|
|
||||||
expect(await _client([_networkError]).canAccessRepo(), isFalse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('canAccessRemote', () {
|
|
||||||
// The RemoteStore spelling must behave exactly like the legacy name.
|
|
||||||
test('is true when the repo endpoint returns 2xx', () async {
|
|
||||||
expect(await _client([_response(200)]).canAccessRemote(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false when the repo is missing (404)', () async {
|
|
||||||
expect(await _client([_response(404)]).canAccessRemote(), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false on a network error', () async {
|
|
||||||
expect(await _client([_networkError]).canAccessRemote(), isFalse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('RemoteStore contract', () {
|
|
||||||
test('GitHubClient is a RemoteStore', () {
|
|
||||||
expect(_client([]), isA<RemoteStore>());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('RepoNotFoundError is catchable as either error type', () {
|
|
||||||
// Backend-neutral callers catch RemoteNotFoundError; existing GitHub
|
|
||||||
// callers catch GitHubSyncError. One exception must satisfy both.
|
|
||||||
final error = RepoNotFoundError('gone');
|
|
||||||
expect(error, isA<GitHubSyncError>());
|
|
||||||
expect(error, isA<RemoteNotFoundError>());
|
|
||||||
expect(error, isA<RemoteSyncError>());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('errors stringify as their own type', () {
|
|
||||||
expect(GitHubSyncError('boom').toString(), 'GitHubSyncError: boom');
|
|
||||||
expect(RepoNotFoundError('gone').toString(), 'RepoNotFoundError: gone');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('deleteFile', () {
|
|
||||||
test('deletes an existing file (resolves its own sha)', () async {
|
|
||||||
final client = _client(
|
|
||||||
[_response(200, {'sha': 'abc123'})],
|
|
||||||
deleteResponse: _response(200),
|
|
||||||
);
|
|
||||||
await expectLater(client.deleteFile('devices/pc/log.json'), completes);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is a no-op when the file does not exist', () async {
|
|
||||||
// sha lookup 404s, repo-exists check 200s -> null sha -> no DELETE sent.
|
|
||||||
final client = _client([
|
|
||||||
_response(404),
|
|
||||||
_response(200),
|
|
||||||
], deleteError: StateError('DELETE must not be called'));
|
|
||||||
await expectLater(client.deleteFile('devices/pc/gone.json'), completes);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws GitHubSyncError on a delete non-2xx response', () async {
|
|
||||||
final client = _client(
|
|
||||||
[_response(200, {'sha': 'abc123'})],
|
|
||||||
deleteResponse: _response(500),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
() => client.deleteFile('devices/pc/log.json'),
|
|
||||||
throwsA(isA<GitHubSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws GitHubSyncError on a delete network exception', () async {
|
|
||||||
final client = _client(
|
|
||||||
[_response(200, {'sha': 'abc123'})],
|
|
||||||
deleteError: http.ClientException('offline'),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
() => client.deleteFile('devices/pc/log.json'),
|
|
||||||
throwsA(isA<GitHubSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
198
crdt_sync_dart/test/github_client_writes_test.dart
Normal file
198
crdt_sync_dart/test/github_client_writes_test.dart
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
http.Response _response(int statusCode, [Object? jsonBody]) =>
|
||||||
|
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
|
||||||
|
|
||||||
|
/// Sentinel meaning "this GET throws a network exception", since a real
|
||||||
|
/// [http.Response] can't carry an invalid status code.
|
||||||
|
final _networkError = Object();
|
||||||
|
|
||||||
|
/// Builds a client whose GETs return [getResponses] in call order (each
|
||||||
|
/// either an [http.Response] or [_networkError]), and whose PUT returns
|
||||||
|
/// [putResponse] (or throws [putError] if network fails).
|
||||||
|
GitHubClient _client(
|
||||||
|
List<Object> getResponses, {
|
||||||
|
http.Response? putResponse,
|
||||||
|
Object? putError,
|
||||||
|
http.Response? deleteResponse,
|
||||||
|
Object? deleteError,
|
||||||
|
}) {
|
||||||
|
var getIndex = 0;
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
if (request.method == 'GET') {
|
||||||
|
final response = getResponses[getIndex];
|
||||||
|
getIndex++;
|
||||||
|
if (identical(response, _networkError)) {
|
||||||
|
throw http.ClientException('offline');
|
||||||
|
}
|
||||||
|
return response as http.Response;
|
||||||
|
}
|
||||||
|
if (request.method == 'PUT') {
|
||||||
|
if (putError != null) throw putError;
|
||||||
|
return putResponse!;
|
||||||
|
}
|
||||||
|
if (request.method == 'DELETE') {
|
||||||
|
if (deleteError != null) throw deleteError;
|
||||||
|
return deleteResponse!;
|
||||||
|
}
|
||||||
|
throw UnsupportedError('unexpected method ${request.method}');
|
||||||
|
});
|
||||||
|
return GitHubClient(
|
||||||
|
owner: 'kuhyx',
|
||||||
|
repo: 'crdt-sync-demo',
|
||||||
|
token: 'fake-token',
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('putFileText', () {
|
||||||
|
test('creates a new file with no sha when none existed', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(404),
|
||||||
|
_response(200),
|
||||||
|
], putResponse: _response(201));
|
||||||
|
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates an existing file by including its sha', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(200, {'sha': 'abc123'}),
|
||||||
|
], putResponse: _response(200));
|
||||||
|
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'throws RepoNotFoundError when checking sha on a missing repo',
|
||||||
|
() async {
|
||||||
|
final client = _client([_response(404), _response(404)]);
|
||||||
|
expect(
|
||||||
|
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<RepoNotFoundError>()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('throws GitHubSyncError when the sha check itself fails', () async {
|
||||||
|
final client = _client([_response(500)]);
|
||||||
|
expect(
|
||||||
|
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<GitHubSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws GitHubSyncError on a put network exception', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(404),
|
||||||
|
_response(200),
|
||||||
|
], putError: http.ClientException('offline'));
|
||||||
|
expect(
|
||||||
|
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<GitHubSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws GitHubSyncError on a put non-2xx response', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(404),
|
||||||
|
_response(200),
|
||||||
|
], putResponse: _response(422));
|
||||||
|
expect(
|
||||||
|
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<GitHubSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('canAccessRepo', () {
|
||||||
|
test('is true when the repo endpoint returns 2xx', () async {
|
||||||
|
expect(await _client([_response(200)]).canAccessRepo(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false when the repo is missing (404)', () async {
|
||||||
|
expect(await _client([_response(404)]).canAccessRepo(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false on a network error', () async {
|
||||||
|
expect(await _client([_networkError]).canAccessRepo(), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('canAccessRemote', () {
|
||||||
|
// The RemoteStore spelling must behave exactly like the legacy name.
|
||||||
|
test('is true when the repo endpoint returns 2xx', () async {
|
||||||
|
expect(await _client([_response(200)]).canAccessRemote(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false when the repo is missing (404)', () async {
|
||||||
|
expect(await _client([_response(404)]).canAccessRemote(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false on a network error', () async {
|
||||||
|
expect(await _client([_networkError]).canAccessRemote(), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RemoteStore contract', () {
|
||||||
|
test('GitHubClient is a RemoteStore', () {
|
||||||
|
expect(_client([]), isA<RemoteStore>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RepoNotFoundError is catchable as either error type', () {
|
||||||
|
// Backend-neutral callers catch RemoteNotFoundError; existing GitHub
|
||||||
|
// callers catch GitHubSyncError. One exception must satisfy both.
|
||||||
|
final error = RepoNotFoundError('gone');
|
||||||
|
expect(error, isA<GitHubSyncError>());
|
||||||
|
expect(error, isA<RemoteNotFoundError>());
|
||||||
|
expect(error, isA<RemoteSyncError>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('errors stringify as their own type', () {
|
||||||
|
expect(GitHubSyncError('boom').toString(), 'GitHubSyncError: boom');
|
||||||
|
expect(RepoNotFoundError('gone').toString(), 'RepoNotFoundError: gone');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('deleteFile', () {
|
||||||
|
test('deletes an existing file (resolves its own sha)', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(200, {'sha': 'abc123'}),
|
||||||
|
], deleteResponse: _response(200));
|
||||||
|
await expectLater(client.deleteFile('devices/pc/log.json'), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op when the file does not exist', () async {
|
||||||
|
// sha lookup 404s, repo-exists check 200s -> null sha -> no DELETE sent.
|
||||||
|
final client = _client([
|
||||||
|
_response(404),
|
||||||
|
_response(200),
|
||||||
|
], deleteError: StateError('DELETE must not be called'));
|
||||||
|
await expectLater(client.deleteFile('devices/pc/gone.json'), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws GitHubSyncError on a delete non-2xx response', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(200, {'sha': 'abc123'}),
|
||||||
|
], deleteResponse: _response(500));
|
||||||
|
expect(
|
||||||
|
() => client.deleteFile('devices/pc/log.json'),
|
||||||
|
throwsA(isA<GitHubSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws GitHubSyncError on a delete network exception', () async {
|
||||||
|
final client = _client([
|
||||||
|
_response(200, {'sha': 'abc123'}),
|
||||||
|
], deleteError: http.ClientException('offline'));
|
||||||
|
expect(
|
||||||
|
() => client.deleteFile('devices/pc/log.json'),
|
||||||
|
throwsA(isA<GitHubSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
196
crdt_sync_dart/test/mirror_store_fallback_test.dart
Normal file
196
crdt_sync_dart/test/mirror_store_fallback_test.dart
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/// Tests for the dual-write decorator used during the GitHub -> Firebase
|
||||||
|
/// cutover.
|
||||||
|
///
|
||||||
|
/// The asymmetry is the whole point and is what these assert: a primary
|
||||||
|
/// failure must fail the tick, a mirror failure must not, and reads must
|
||||||
|
/// consult both so that a half-migrated app (one device moved, one not) still
|
||||||
|
/// converges in both directions.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A scriptable in-memory [RemoteStore] that can be told to fail.
|
||||||
|
class _FakeStore implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeStore({Map<String, String>? files, this.failing = false})
|
||||||
|
: files = files ?? {};
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
bool failing;
|
||||||
|
final List<String> writes = [];
|
||||||
|
bool closed = false;
|
||||||
|
|
||||||
|
void _guard(String what) {
|
||||||
|
if (failing) throw RemoteSyncError('$what failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async {
|
||||||
|
_guard('list');
|
||||||
|
return files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
_guard('read');
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
_guard('write');
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
_guard('map read');
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async {
|
||||||
|
_guard('delete');
|
||||||
|
files.remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => !failing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store with no bulk-map capability, standing in for [GitHubClient].
|
||||||
|
class _FakeStoreWithoutBulkRead implements RemoteStore {
|
||||||
|
_FakeStoreWithoutBulkRead([Map<String, String>? files])
|
||||||
|
: _inner = _FakeStore(files: files);
|
||||||
|
|
||||||
|
final _FakeStore _inner;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) => _inner.getFileText(path);
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(String p, String t, {required String message}) =>
|
||||||
|
_inner.putFileText(p, t, message: message);
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||||
|
_inner.deleteFile(path, message: message);
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
||||||
|
@override
|
||||||
|
void close() => _inner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
({
|
||||||
|
MirrorStore store,
|
||||||
|
_FakeStore primary,
|
||||||
|
_FakeStore mirror,
|
||||||
|
List<String> failures,
|
||||||
|
})
|
||||||
|
_mirror({
|
||||||
|
Map<String, String>? primaryFiles,
|
||||||
|
Map<String, String>? mirrorFiles,
|
||||||
|
bool primaryFailing = false,
|
||||||
|
bool mirrorFailing = false,
|
||||||
|
}) {
|
||||||
|
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
|
||||||
|
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
|
||||||
|
final failures = <String>[];
|
||||||
|
return (
|
||||||
|
store: MirrorStore(
|
||||||
|
primary: primary,
|
||||||
|
mirror: mirror,
|
||||||
|
onMirrorFailure: (operation, _) => failures.add(operation),
|
||||||
|
),
|
||||||
|
primary: primary,
|
||||||
|
mirror: mirror,
|
||||||
|
failures: failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('primary read fallback', () {
|
||||||
|
test('getFileText falls back to the mirror', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFailing: true,
|
||||||
|
mirrorFiles: {'ns/phone/log.json': '{}'},
|
||||||
|
);
|
||||||
|
expect(await m.store.getFileText('ns/phone/log.json'), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFileText throws when both fail', () async {
|
||||||
|
final m = _mirror(primaryFailing: true, mirrorFailing: true);
|
||||||
|
await expectLater(
|
||||||
|
() => m.store.getFileText('ns/phone/log.json'),
|
||||||
|
throwsA(isA<RemoteSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFileText throws when primary fails and mirror lacks it', () async {
|
||||||
|
// A reachable mirror that simply does not hold the file is not an
|
||||||
|
// answer either, since the primary's copy was never read.
|
||||||
|
final m = _mirror(primaryFailing: true);
|
||||||
|
await expectLater(
|
||||||
|
() => m.store.getFileText('ns/phone/log.json'),
|
||||||
|
throwsA(isA<RemoteSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStringMap falls back to the mirror', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFailing: true,
|
||||||
|
mirrorFiles: {'ns/revs/phone': 'r1'},
|
||||||
|
);
|
||||||
|
expect(await m.store.getStringMap('ns/revs'), {'phone': 'r1'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStringMap throws when both fail', () async {
|
||||||
|
final m = _mirror(primaryFailing: true, mirrorFailing: true);
|
||||||
|
await expectLater(
|
||||||
|
() => m.store.getStringMap('ns/revs'),
|
||||||
|
throwsA(isA<RemoteSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStringMap tolerates a mirror that cannot bulk-read', () async {
|
||||||
|
// A mirror with no bulk-read capability (GitHub) contributes nothing,
|
||||||
|
// but that is not a failure -- it simply has no revisions to add, so a
|
||||||
|
// working primary's map must still come back.
|
||||||
|
final store = MirrorStore(
|
||||||
|
primary: _FakeStore(files: {'ns/revs/pc': 'r1'}),
|
||||||
|
mirror: _FakeStoreWithoutBulkRead(),
|
||||||
|
);
|
||||||
|
expect(await store.getStringMap('ns/revs'), {'pc': 'r1'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes stay fail-closed on a primary failure', () async {
|
||||||
|
// The read fix must NOT loosen writes: an unaccepted write has not
|
||||||
|
// happened, so it must still fail the tick.
|
||||||
|
final m = _mirror(primaryFailing: true);
|
||||||
|
await expectLater(
|
||||||
|
() => m.store.putFileText('ns/pc/log.json', '{}', message: 'm'),
|
||||||
|
throwsA(isA<RemoteSyncError>()),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
() => m.store.deleteFile('ns/pc/log.json'),
|
||||||
|
throwsA(isA<RemoteSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
153
crdt_sync_dart/test/mirror_store_reads_test.dart
Normal file
153
crdt_sync_dart/test/mirror_store_reads_test.dart
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/// Tests for the dual-write decorator used during the GitHub -> Firebase
|
||||||
|
/// cutover.
|
||||||
|
///
|
||||||
|
/// The asymmetry is the whole point and is what these assert: a primary
|
||||||
|
/// failure must fail the tick, a mirror failure must not, and reads must
|
||||||
|
/// consult both so that a half-migrated app (one device moved, one not) still
|
||||||
|
/// converges in both directions.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A scriptable in-memory [RemoteStore] that can be told to fail.
|
||||||
|
class _FakeStore implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeStore({Map<String, String>? files, this.failing = false})
|
||||||
|
: files = files ?? {};
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
bool failing;
|
||||||
|
final List<String> writes = [];
|
||||||
|
bool closed = false;
|
||||||
|
|
||||||
|
void _guard(String what) {
|
||||||
|
if (failing) throw RemoteSyncError('$what failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async {
|
||||||
|
_guard('list');
|
||||||
|
return files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
_guard('read');
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
_guard('write');
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
_guard('map read');
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async {
|
||||||
|
_guard('delete');
|
||||||
|
files.remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => !failing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
({
|
||||||
|
MirrorStore store,
|
||||||
|
_FakeStore primary,
|
||||||
|
_FakeStore mirror,
|
||||||
|
List<String> failures,
|
||||||
|
})
|
||||||
|
_mirror({
|
||||||
|
Map<String, String>? primaryFiles,
|
||||||
|
Map<String, String>? mirrorFiles,
|
||||||
|
bool primaryFailing = false,
|
||||||
|
bool mirrorFailing = false,
|
||||||
|
}) {
|
||||||
|
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
|
||||||
|
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
|
||||||
|
final failures = <String>[];
|
||||||
|
return (
|
||||||
|
store: MirrorStore(
|
||||||
|
primary: primary,
|
||||||
|
mirror: mirror,
|
||||||
|
onMirrorFailure: (operation, _) => failures.add(operation),
|
||||||
|
),
|
||||||
|
primary: primary,
|
||||||
|
mirror: mirror,
|
||||||
|
failures: failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('listDirectory', () {
|
||||||
|
test('unions devices from both backends', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFiles: {'ns/pc/log.json': '{}'},
|
||||||
|
mirrorFiles: {'ns/phone/log.json': '{}'},
|
||||||
|
);
|
||||||
|
expect(await m.store.listDirectory('ns'), containsAll(['pc', 'phone']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not duplicate a device present in both', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFiles: {'ns/pc/log.json': '{}'},
|
||||||
|
mirrorFiles: {'ns/pc/log.json': '{}'},
|
||||||
|
);
|
||||||
|
expect(await m.store.listDirectory('ns'), ['pc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mirror failure degrades to the primary list', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFiles: {'ns/pc/log.json': '{}'},
|
||||||
|
mirrorFailing: true,
|
||||||
|
);
|
||||||
|
expect(await m.store.listDirectory('ns'), ['pc']);
|
||||||
|
expect(m.failures, ['listDirectory ns']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a primary failure degrades to the mirror list', () async {
|
||||||
|
// READS are resilient on both sides: a Firebase outage must not hide
|
||||||
|
// the mirror's devices. This used to throw, which made a primary outage
|
||||||
|
// look like "no devices exist" -- the union read silently degrading to
|
||||||
|
// nothing, precisely when the fallback was needed.
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFailing: true,
|
||||||
|
mirrorFiles: {'ns/phone/log.json': '{}'},
|
||||||
|
);
|
||||||
|
expect(await m.store.listDirectory('ns'), ['phone']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both backends failing throws', () async {
|
||||||
|
// With no answer from either side, fail closed: an empty list is
|
||||||
|
// indistinguishable from "no devices", and callers act on that.
|
||||||
|
final m = _mirror(primaryFailing: true, mirrorFailing: true);
|
||||||
|
await expectLater(
|
||||||
|
() => m.store.listDirectory('ns'),
|
||||||
|
throwsA(isA<RemoteSyncError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
248
crdt_sync_dart/test/mirror_store_revisions_test.dart
Normal file
248
crdt_sync_dart/test/mirror_store_revisions_test.dart
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/// Tests for the dual-write decorator used during the GitHub -> Firebase
|
||||||
|
/// cutover.
|
||||||
|
///
|
||||||
|
/// The asymmetry is the whole point and is what these assert: a primary
|
||||||
|
/// failure must fail the tick, a mirror failure must not, and reads must
|
||||||
|
/// consult both so that a half-migrated app (one device moved, one not) still
|
||||||
|
/// converges in both directions.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A scriptable in-memory [RemoteStore] that can be told to fail.
|
||||||
|
class _FakeStore implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeStore({Map<String, String>? files, this.failing = false})
|
||||||
|
: files = files ?? {};
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
bool failing;
|
||||||
|
final List<String> writes = [];
|
||||||
|
bool closed = false;
|
||||||
|
|
||||||
|
void _guard(String what) {
|
||||||
|
if (failing) throw RemoteSyncError('$what failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async {
|
||||||
|
_guard('list');
|
||||||
|
return files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
_guard('read');
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
_guard('write');
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
_guard('map read');
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async {
|
||||||
|
_guard('delete');
|
||||||
|
files.remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => !failing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store with no bulk-map capability, standing in for [GitHubClient].
|
||||||
|
class _FakeStoreWithoutBulkRead implements RemoteStore {
|
||||||
|
_FakeStoreWithoutBulkRead([Map<String, String>? files])
|
||||||
|
: _inner = _FakeStore(files: files);
|
||||||
|
|
||||||
|
final _FakeStore _inner;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) => _inner.getFileText(path);
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(String p, String t, {required String message}) =>
|
||||||
|
_inner.putFileText(p, t, message: message);
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||||
|
_inner.deleteFile(path, message: message);
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
||||||
|
@override
|
||||||
|
void close() => _inner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
({
|
||||||
|
MirrorStore store,
|
||||||
|
_FakeStore primary,
|
||||||
|
_FakeStore mirror,
|
||||||
|
List<String> failures,
|
||||||
|
})
|
||||||
|
_mirror({
|
||||||
|
Map<String, String>? primaryFiles,
|
||||||
|
Map<String, String>? mirrorFiles,
|
||||||
|
bool primaryFailing = false,
|
||||||
|
bool mirrorFailing = false,
|
||||||
|
}) {
|
||||||
|
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
|
||||||
|
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
|
||||||
|
final failures = <String>[];
|
||||||
|
return (
|
||||||
|
store: MirrorStore(
|
||||||
|
primary: primary,
|
||||||
|
mirror: mirror,
|
||||||
|
onMirrorFailure: (operation, _) => failures.add(operation),
|
||||||
|
),
|
||||||
|
primary: primary,
|
||||||
|
mirror: mirror,
|
||||||
|
failures: failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('revision maps', () {
|
||||||
|
test('merge both backends, primary winning on conflict', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFiles: {'ns/revs/pc': 'primary-rev'},
|
||||||
|
mirrorFiles: {'ns/revs/pc': 'mirror-rev', 'ns/revs/phone': 'old-rev'},
|
||||||
|
);
|
||||||
|
expect(await m.store.getStringMap('ns/revs'), {
|
||||||
|
'pc': 'primary-rev',
|
||||||
|
// An un-migrated device publishes revisions only to the mirror;
|
||||||
|
// without this it would look revision-less and be re-downloaded
|
||||||
|
// every tick for the whole trial.
|
||||||
|
'phone': 'old-rev',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mirror failure degrades to primary revisions only', () async {
|
||||||
|
final m = _mirror(
|
||||||
|
primaryFiles: {'ns/revs/pc': 'primary-rev'},
|
||||||
|
mirrorFailing: true,
|
||||||
|
);
|
||||||
|
expect(await m.store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
|
||||||
|
expect(m.failures, ['getStringMap ns/revs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a backend without bulk reads simply contributes nothing', () async {
|
||||||
|
final store = MirrorStore(
|
||||||
|
primary: _FakeStore(files: {'ns/revs/pc': 'primary-rev'}),
|
||||||
|
mirror: _FakeStoreWithoutBulkRead({'ns/revs/phone': 'ignored'}),
|
||||||
|
);
|
||||||
|
expect(await store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is empty when the primary has no bulk reads either', () async {
|
||||||
|
final store = MirrorStore(
|
||||||
|
primary: _FakeStoreWithoutBulkRead(),
|
||||||
|
mirror: _FakeStoreWithoutBulkRead(),
|
||||||
|
);
|
||||||
|
expect(await store.getStringMap('ns/revs'), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('lifecycle', () {
|
||||||
|
test('canAccessRemote reports only the primary', () async {
|
||||||
|
// A Test-connection button must not report success because the backend
|
||||||
|
// being retired happens to answer.
|
||||||
|
final m = _mirror(primaryFailing: true);
|
||||||
|
expect(await m.store.canAccessRemote(), isFalse);
|
||||||
|
final healthy = _mirror(mirrorFailing: true);
|
||||||
|
expect(await healthy.store.canAccessRemote(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close releases both backends', () {
|
||||||
|
final m = _mirror();
|
||||||
|
m.store.close();
|
||||||
|
expect(m.primary.closed, isTrue);
|
||||||
|
expect(m.mirror.closed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mirror failure with no handler is still swallowed', () async {
|
||||||
|
final store = MirrorStore(
|
||||||
|
primary: _FakeStore(),
|
||||||
|
mirror: _FakeStore(failing: true),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
store.putFileText('ns/pc/log.json', '{}', message: 'm'),
|
||||||
|
completes,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is itself a RemoteStore, so syncLog accepts it', () {
|
||||||
|
expect(_mirror().store, isA<RemoteStore>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('end to end through syncLog', () {
|
||||||
|
test('a half-migrated pair still converges both ways', () async {
|
||||||
|
// desktop is on Firebase+mirror; phone has not migrated and still
|
||||||
|
// writes only to GitHub. Both must end up with both records.
|
||||||
|
final github = _FakeStore();
|
||||||
|
final firebase = _FakeStore();
|
||||||
|
|
||||||
|
Log log(String id, String nodeId) => {
|
||||||
|
id: Record(
|
||||||
|
id: id,
|
||||||
|
fields: {
|
||||||
|
'v': (id, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// The library's own serialization, so this exercises the same
|
||||||
|
// encoding the apps use rather than a test-only one.
|
||||||
|
const encode = logToJson;
|
||||||
|
const decode = logFromJson;
|
||||||
|
|
||||||
|
// Un-migrated phone: GitHub only.
|
||||||
|
await syncLog(
|
||||||
|
client: github,
|
||||||
|
deviceId: 'phone',
|
||||||
|
pathPrefix: 'ns/devices',
|
||||||
|
localLog: log('from-phone', 'node-phone'),
|
||||||
|
encode: encode,
|
||||||
|
decode: decode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migrated desktop: Firebase primary, GitHub mirror.
|
||||||
|
final merged = await syncLog(
|
||||||
|
client: MirrorStore(primary: firebase, mirror: github),
|
||||||
|
deviceId: 'desktop',
|
||||||
|
pathPrefix: 'ns/devices',
|
||||||
|
localLog: log('from-desktop', 'node-desktop'),
|
||||||
|
encode: encode,
|
||||||
|
decode: decode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.keys, containsAll(['from-phone', 'from-desktop']));
|
||||||
|
// The desktop's merged result is mirrored back to GitHub, so the
|
||||||
|
// un-migrated phone sees it on its next tick.
|
||||||
|
expect(github.files['ns/devices/desktop/log.json'], isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -74,29 +74,6 @@ class _FakeStore implements RemoteStore, BulkMapReader {
|
|||||||
void close() => closed = true;
|
void close() => closed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A store with no bulk-map capability, standing in for [GitHubClient].
|
|
||||||
class _FakeStoreWithoutBulkRead implements RemoteStore {
|
|
||||||
_FakeStoreWithoutBulkRead([Map<String, String>? files])
|
|
||||||
: _inner = _FakeStore(files: files);
|
|
||||||
|
|
||||||
final _FakeStore _inner;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
|
||||||
@override
|
|
||||||
Future<String?> getFileText(String path) => _inner.getFileText(path);
|
|
||||||
@override
|
|
||||||
Future<void> putFileText(String p, String t, {required String message}) =>
|
|
||||||
_inner.putFileText(p, t, message: message);
|
|
||||||
@override
|
|
||||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
|
||||||
_inner.deleteFile(path, message: message);
|
|
||||||
@override
|
|
||||||
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
|
||||||
@override
|
|
||||||
void close() => _inner.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
({
|
({
|
||||||
MirrorStore store,
|
MirrorStore store,
|
||||||
_FakeStore primary,
|
_FakeStore primary,
|
||||||
@ -210,243 +187,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('listDirectory', () {
|
|
||||||
test('unions devices from both backends', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFiles: {'ns/pc/log.json': '{}'},
|
|
||||||
mirrorFiles: {'ns/phone/log.json': '{}'},
|
|
||||||
);
|
|
||||||
expect(await m.store.listDirectory('ns'), containsAll(['pc', 'phone']));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not duplicate a device present in both', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFiles: {'ns/pc/log.json': '{}'},
|
|
||||||
mirrorFiles: {'ns/pc/log.json': '{}'},
|
|
||||||
);
|
|
||||||
expect(await m.store.listDirectory('ns'), ['pc']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a mirror failure degrades to the primary list', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFiles: {'ns/pc/log.json': '{}'},
|
|
||||||
mirrorFailing: true,
|
|
||||||
);
|
|
||||||
expect(await m.store.listDirectory('ns'), ['pc']);
|
|
||||||
expect(m.failures, ['listDirectory ns']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a primary failure degrades to the mirror list', () async {
|
|
||||||
// READS are resilient on both sides: a Firebase outage must not hide
|
|
||||||
// the mirror's devices. This used to throw, which made a primary outage
|
|
||||||
// look like "no devices exist" -- the union read silently degrading to
|
|
||||||
// nothing, precisely when the fallback was needed.
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFailing: true,
|
|
||||||
mirrorFiles: {'ns/phone/log.json': '{}'},
|
|
||||||
);
|
|
||||||
expect(await m.store.listDirectory('ns'), ['phone']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('both backends failing throws', () async {
|
|
||||||
// With no answer from either side, fail closed: an empty list is
|
|
||||||
// indistinguishable from "no devices", and callers act on that.
|
|
||||||
final m = _mirror(primaryFailing: true, mirrorFailing: true);
|
|
||||||
await expectLater(
|
|
||||||
() => m.store.listDirectory('ns'),
|
|
||||||
throwsA(isA<RemoteSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('primary read fallback', () {
|
|
||||||
test('getFileText falls back to the mirror', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFailing: true,
|
|
||||||
mirrorFiles: {'ns/phone/log.json': '{}'},
|
|
||||||
);
|
|
||||||
expect(await m.store.getFileText('ns/phone/log.json'), '{}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getFileText throws when both fail', () async {
|
|
||||||
final m = _mirror(primaryFailing: true, mirrorFailing: true);
|
|
||||||
await expectLater(
|
|
||||||
() => m.store.getFileText('ns/phone/log.json'),
|
|
||||||
throwsA(isA<RemoteSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getFileText throws when primary fails and mirror lacks it', () async {
|
|
||||||
// A reachable mirror that simply does not hold the file is not an
|
|
||||||
// answer either, since the primary's copy was never read.
|
|
||||||
final m = _mirror(primaryFailing: true);
|
|
||||||
await expectLater(
|
|
||||||
() => m.store.getFileText('ns/phone/log.json'),
|
|
||||||
throwsA(isA<RemoteSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getStringMap falls back to the mirror', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFailing: true,
|
|
||||||
mirrorFiles: {'ns/revs/phone': 'r1'},
|
|
||||||
);
|
|
||||||
expect(await m.store.getStringMap('ns/revs'), {'phone': 'r1'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getStringMap throws when both fail', () async {
|
|
||||||
final m = _mirror(primaryFailing: true, mirrorFailing: true);
|
|
||||||
await expectLater(
|
|
||||||
() => m.store.getStringMap('ns/revs'),
|
|
||||||
throwsA(isA<RemoteSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getStringMap tolerates a mirror that cannot bulk-read', () async {
|
|
||||||
// A mirror with no bulk-read capability (GitHub) contributes nothing,
|
|
||||||
// but that is not a failure -- it simply has no revisions to add, so a
|
|
||||||
// working primary's map must still come back.
|
|
||||||
final store = MirrorStore(
|
|
||||||
primary: _FakeStore(files: {'ns/revs/pc': 'r1'}),
|
|
||||||
mirror: _FakeStoreWithoutBulkRead(),
|
|
||||||
);
|
|
||||||
expect(await store.getStringMap('ns/revs'), {'pc': 'r1'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes stay fail-closed on a primary failure', () async {
|
|
||||||
// The read fix must NOT loosen writes: an unaccepted write has not
|
|
||||||
// happened, so it must still fail the tick.
|
|
||||||
final m = _mirror(primaryFailing: true);
|
|
||||||
await expectLater(
|
|
||||||
() => m.store.putFileText('ns/pc/log.json', '{}', message: 'm'),
|
|
||||||
throwsA(isA<RemoteSyncError>()),
|
|
||||||
);
|
|
||||||
await expectLater(
|
|
||||||
() => m.store.deleteFile('ns/pc/log.json'),
|
|
||||||
throwsA(isA<RemoteSyncError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('revision maps', () {
|
|
||||||
test('merge both backends, primary winning on conflict', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFiles: {'ns/revs/pc': 'primary-rev'},
|
|
||||||
mirrorFiles: {'ns/revs/pc': 'mirror-rev', 'ns/revs/phone': 'old-rev'},
|
|
||||||
);
|
|
||||||
expect(await m.store.getStringMap('ns/revs'), {
|
|
||||||
'pc': 'primary-rev',
|
|
||||||
// An un-migrated device publishes revisions only to the mirror;
|
|
||||||
// without this it would look revision-less and be re-downloaded
|
|
||||||
// every tick for the whole trial.
|
|
||||||
'phone': 'old-rev',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a mirror failure degrades to primary revisions only', () async {
|
|
||||||
final m = _mirror(
|
|
||||||
primaryFiles: {'ns/revs/pc': 'primary-rev'},
|
|
||||||
mirrorFailing: true,
|
|
||||||
);
|
|
||||||
expect(await m.store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
|
|
||||||
expect(m.failures, ['getStringMap ns/revs']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a backend without bulk reads simply contributes nothing', () async {
|
|
||||||
final store = MirrorStore(
|
|
||||||
primary: _FakeStore(files: {'ns/revs/pc': 'primary-rev'}),
|
|
||||||
mirror: _FakeStoreWithoutBulkRead({'ns/revs/phone': 'ignored'}),
|
|
||||||
);
|
|
||||||
expect(await store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is empty when the primary has no bulk reads either', () async {
|
|
||||||
final store = MirrorStore(
|
|
||||||
primary: _FakeStoreWithoutBulkRead(),
|
|
||||||
mirror: _FakeStoreWithoutBulkRead(),
|
|
||||||
);
|
|
||||||
expect(await store.getStringMap('ns/revs'), isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('lifecycle', () {
|
|
||||||
test('canAccessRemote reports only the primary', () async {
|
|
||||||
// A Test-connection button must not report success because the backend
|
|
||||||
// being retired happens to answer.
|
|
||||||
final m = _mirror(primaryFailing: true);
|
|
||||||
expect(await m.store.canAccessRemote(), isFalse);
|
|
||||||
final healthy = _mirror(mirrorFailing: true);
|
|
||||||
expect(await healthy.store.canAccessRemote(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('close releases both backends', () {
|
|
||||||
final m = _mirror();
|
|
||||||
m.store.close();
|
|
||||||
expect(m.primary.closed, isTrue);
|
|
||||||
expect(m.mirror.closed, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a mirror failure with no handler is still swallowed', () async {
|
|
||||||
final store = MirrorStore(
|
|
||||||
primary: _FakeStore(),
|
|
||||||
mirror: _FakeStore(failing: true),
|
|
||||||
);
|
|
||||||
await expectLater(
|
|
||||||
store.putFileText('ns/pc/log.json', '{}', message: 'm'),
|
|
||||||
completes,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is itself a RemoteStore, so syncLog accepts it', () {
|
|
||||||
expect(_mirror().store, isA<RemoteStore>());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('end to end through syncLog', () {
|
|
||||||
test('a half-migrated pair still converges both ways', () async {
|
|
||||||
// desktop is on Firebase+mirror; phone has not migrated and still
|
|
||||||
// writes only to GitHub. Both must end up with both records.
|
|
||||||
final github = _FakeStore();
|
|
||||||
final firebase = _FakeStore();
|
|
||||||
|
|
||||||
Log log(String id, String nodeId) => {
|
|
||||||
id: Record(
|
|
||||||
id: id,
|
|
||||||
fields: {
|
|
||||||
'v': (id, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
// The library's own serialization, so this exercises the same
|
|
||||||
// encoding the apps use rather than a test-only one.
|
|
||||||
const encode = logToJson;
|
|
||||||
const decode = logFromJson;
|
|
||||||
|
|
||||||
// Un-migrated phone: GitHub only.
|
|
||||||
await syncLog(
|
|
||||||
client: github,
|
|
||||||
deviceId: 'phone',
|
|
||||||
pathPrefix: 'ns/devices',
|
|
||||||
localLog: log('from-phone', 'node-phone'),
|
|
||||||
encode: encode,
|
|
||||||
decode: decode,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Migrated desktop: Firebase primary, GitHub mirror.
|
|
||||||
final merged = await syncLog(
|
|
||||||
client: MirrorStore(primary: firebase, mirror: github),
|
|
||||||
deviceId: 'desktop',
|
|
||||||
pathPrefix: 'ns/devices',
|
|
||||||
localLog: log('from-desktop', 'node-desktop'),
|
|
||||||
encode: encode,
|
|
||||||
decode: decode,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged.keys, containsAll(['from-phone', 'from-desktop']));
|
|
||||||
// The desktop's merged result is mirrored back to GitHub, so the
|
|
||||||
// un-migrated phone sees it on its next tick.
|
|
||||||
expect(github.files['ns/devices/desktop/log.json'], isNotNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
158
crdt_sync_dart/test/sync_revisions_backends_test.dart
Normal file
158
crdt_sync_dart/test/sync_revisions_backends_test.dart
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/// Tests for the revision tracking that keeps sync inside the free tier.
|
||||||
|
///
|
||||||
|
/// Two savings, both measured against the GitHub-backed history this
|
||||||
|
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
|
||||||
|
/// re-downloaded every peer's whole log regardless of whether it had changed.
|
||||||
|
/// These tests assert the *request counts*, since that -- not the merge
|
||||||
|
/// result -- is what the free-tier headroom depends on.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
|
||||||
|
id: Record(
|
||||||
|
id: id,
|
||||||
|
fields: {
|
||||||
|
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _encode(Log log) =>
|
||||||
|
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
|
||||||
|
|
||||||
|
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
|
||||||
|
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// An in-memory [RemoteStore] that also serves revision maps in one read,
|
||||||
|
/// standing in for [FirebaseRestClient]. Counts every call so tests can
|
||||||
|
/// assert on traffic rather than on results.
|
||||||
|
class _FakeRemote implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeRemote(this.files);
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
final List<String> reads = [];
|
||||||
|
final List<String> writes = [];
|
||||||
|
final List<String> mapReads = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async => files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
reads.add(path);
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
mapReads.add(path);
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||||
|
files.remove(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [RemoteStore] with no bulk-map capability, standing in for
|
||||||
|
/// [GitHubClient]: revision tracking must degrade, not break.
|
||||||
|
///
|
||||||
|
/// Delegates rather than extends, because a subclass of [_FakeRemote] would
|
||||||
|
/// inherit `BulkMapReader` -- the very capability this fake exists to lack.
|
||||||
|
class _FakeRemoteWithoutBulkRead implements RemoteStore {
|
||||||
|
_FakeRemoteWithoutBulkRead(Map<String, String> files)
|
||||||
|
: _inner = _FakeRemote(files);
|
||||||
|
|
||||||
|
final _FakeRemote _inner;
|
||||||
|
|
||||||
|
Map<String, String> get files => _inner.files;
|
||||||
|
List<String> get reads => _inner.reads;
|
||||||
|
List<String> get writes => _inner.writes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) => _inner.getFileText(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) => _inner.putFileText(path, text, message: message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||||
|
_inner.deleteFile(path, message: message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => _inner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Log> _tick(
|
||||||
|
RemoteStore remote,
|
||||||
|
SyncStateStore? store, {
|
||||||
|
Log local = const {},
|
||||||
|
String deviceId = 'pc',
|
||||||
|
}) => syncLog(
|
||||||
|
client: remote,
|
||||||
|
deviceId: deviceId,
|
||||||
|
pathPrefix: 'ns/devices',
|
||||||
|
localLog: local,
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
stateStore: store,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('backends without bulk-map reads', () {
|
||||||
|
test('still sync correctly, just without the saving', () async {
|
||||||
|
// GitHubClient has no BulkMapReader, so revision lookup degrades to
|
||||||
|
// "fetch everything" -- correctness must not depend on the optimisation.
|
||||||
|
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
||||||
|
final remote = _FakeRemoteWithoutBulkRead({
|
||||||
|
'ns/devices/phone/log.json': peer,
|
||||||
|
});
|
||||||
|
final store = InMemorySyncStateStore();
|
||||||
|
final first = await _tick(remote, store);
|
||||||
|
expect(first.keys, contains('b'));
|
||||||
|
|
||||||
|
remote.reads.clear();
|
||||||
|
await _tick(remote, store, local: first);
|
||||||
|
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
125
crdt_sync_dart/test/sync_revisions_order_test.dart
Normal file
125
crdt_sync_dart/test/sync_revisions_order_test.dart
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/// Tests for the revision tracking that keeps sync inside the free tier.
|
||||||
|
///
|
||||||
|
/// Two savings, both measured against the GitHub-backed history this
|
||||||
|
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
|
||||||
|
/// re-downloaded every peer's whole log regardless of whether it had changed.
|
||||||
|
/// These tests assert the *request counts*, since that -- not the merge
|
||||||
|
/// result -- is what the free-tier headroom depends on.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
|
||||||
|
id: Record(
|
||||||
|
id: id,
|
||||||
|
fields: {
|
||||||
|
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _encode(Log log) =>
|
||||||
|
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
|
||||||
|
|
||||||
|
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
|
||||||
|
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// An in-memory [RemoteStore] that also serves revision maps in one read,
|
||||||
|
/// standing in for [FirebaseRestClient]. Counts every call so tests can
|
||||||
|
/// assert on traffic rather than on results.
|
||||||
|
class _FakeRemote implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeRemote(this.files);
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
final List<String> reads = [];
|
||||||
|
final List<String> writes = [];
|
||||||
|
final List<String> mapReads = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async => files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
reads.add(path);
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
mapReads.add(path);
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||||
|
files.remove(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Log> _tick(
|
||||||
|
RemoteStore remote,
|
||||||
|
SyncStateStore? store, {
|
||||||
|
Log local = const {},
|
||||||
|
String deviceId = 'pc',
|
||||||
|
}) => syncLog(
|
||||||
|
client: remote,
|
||||||
|
deviceId: deviceId,
|
||||||
|
pathPrefix: 'ns/devices',
|
||||||
|
localLog: local,
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
stateStore: store,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('revision publishing order', () {
|
||||||
|
test('publishes the log before its revision', () async {
|
||||||
|
// Reversed, a peer would cache "seen rev X" against a log it never
|
||||||
|
// received, and skip it forever.
|
||||||
|
final remote = _FakeRemote({});
|
||||||
|
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
|
||||||
|
expect(
|
||||||
|
remote.writes.indexOf('ns/devices/pc/log.json'),
|
||||||
|
lessThan(remote.writes.indexOf('ns/revs/pc')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each device writes only its own revision key', () async {
|
||||||
|
// Per-device keys rather than one shared map: a whole-map write would
|
||||||
|
// erase every other device's entry, after which those peers would look
|
||||||
|
// permanently unchanged and never be fetched again.
|
||||||
|
final remote = _FakeRemote({'ns/revs/phone': 'peer-rev'});
|
||||||
|
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
|
||||||
|
expect(remote.files['ns/revs/phone'], 'peer-rev');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PersistedSyncStateStore', () {});
|
||||||
|
}
|
||||||
193
crdt_sync_dart/test/sync_revisions_peers_test.dart
Normal file
193
crdt_sync_dart/test/sync_revisions_peers_test.dart
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/// Tests for the revision tracking that keeps sync inside the free tier.
|
||||||
|
///
|
||||||
|
/// Two savings, both measured against the GitHub-backed history this
|
||||||
|
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
|
||||||
|
/// re-downloaded every peer's whole log regardless of whether it had changed.
|
||||||
|
/// These tests assert the *request counts*, since that -- not the merge
|
||||||
|
/// result -- is what the free-tier headroom depends on.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
|
||||||
|
id: Record(
|
||||||
|
id: id,
|
||||||
|
fields: {
|
||||||
|
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _encode(Log log) =>
|
||||||
|
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
|
||||||
|
|
||||||
|
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
|
||||||
|
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// An in-memory [RemoteStore] that also serves revision maps in one read,
|
||||||
|
/// standing in for [FirebaseRestClient]. Counts every call so tests can
|
||||||
|
/// assert on traffic rather than on results.
|
||||||
|
class _FakeRemote implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeRemote(this.files);
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
final List<String> reads = [];
|
||||||
|
final List<String> writes = [];
|
||||||
|
final List<String> mapReads = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async => files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
reads.add(path);
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
mapReads.add(path);
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||||
|
files.remove(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Log> _tick(
|
||||||
|
RemoteStore remote,
|
||||||
|
SyncStateStore? store, {
|
||||||
|
Log local = const {},
|
||||||
|
String deviceId = 'pc',
|
||||||
|
}) => syncLog(
|
||||||
|
client: remote,
|
||||||
|
deviceId: deviceId,
|
||||||
|
pathPrefix: 'ns/devices',
|
||||||
|
localLog: local,
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
stateStore: store,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('peer download suppression', () {
|
||||||
|
test('downloads a peer whose revision it has never seen', () async {
|
||||||
|
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
||||||
|
final remote = _FakeRemote({
|
||||||
|
'ns/devices/phone/log.json': peer,
|
||||||
|
'ns/revs/phone': revisionOf(peer),
|
||||||
|
});
|
||||||
|
final merged = await _tick(remote, InMemorySyncStateStore());
|
||||||
|
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
||||||
|
expect(merged.keys, contains('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips the download when the peer revision is unchanged', () async {
|
||||||
|
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
||||||
|
final remote = _FakeRemote({
|
||||||
|
'ns/devices/phone/log.json': peer,
|
||||||
|
'ns/revs/phone': revisionOf(peer),
|
||||||
|
});
|
||||||
|
final store = InMemorySyncStateStore();
|
||||||
|
final first = await _tick(remote, store);
|
||||||
|
remote.reads.clear();
|
||||||
|
|
||||||
|
// The peer's records are already in the local log from tick one, so
|
||||||
|
// re-downloading them is pure waste -- this is the ~700 MB/month that
|
||||||
|
// the revision check removes.
|
||||||
|
await _tick(remote, store, local: first);
|
||||||
|
expect(remote.reads, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads again once the peer publishes a new revision', () async {
|
||||||
|
final peerV1 = _encode(_log('b', 'v1', nodeId: 'node-b'));
|
||||||
|
final remote = _FakeRemote({
|
||||||
|
'ns/devices/phone/log.json': peerV1,
|
||||||
|
'ns/revs/phone': revisionOf(peerV1),
|
||||||
|
});
|
||||||
|
final store = InMemorySyncStateStore();
|
||||||
|
final first = await _tick(remote, store);
|
||||||
|
remote.reads.clear();
|
||||||
|
|
||||||
|
final peerV2 = _encode({
|
||||||
|
..._decode(peerV1),
|
||||||
|
..._log('c', 'v2', nodeId: 'node-b'),
|
||||||
|
});
|
||||||
|
remote.files['ns/devices/phone/log.json'] = peerV2;
|
||||||
|
remote.files['ns/revs/phone'] = revisionOf(peerV2);
|
||||||
|
|
||||||
|
final second = await _tick(remote, store, local: first);
|
||||||
|
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
||||||
|
expect(second.keys, containsAll(['b', 'c']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-downloads a peer whose push was corrupt', () async {
|
||||||
|
// A failed decode must not be remembered as seen, or the corruption
|
||||||
|
// would be permanent.
|
||||||
|
final remote = _FakeRemote({
|
||||||
|
'ns/devices/phone/log.json': 'not json at all',
|
||||||
|
'ns/revs/phone': revisionOf('not json at all'),
|
||||||
|
});
|
||||||
|
final store = InMemorySyncStateStore();
|
||||||
|
await _tick(remote, store);
|
||||||
|
remote.reads.clear();
|
||||||
|
|
||||||
|
await _tick(remote, store);
|
||||||
|
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads when the peer has published no revision yet', () async {
|
||||||
|
// A device still running the pre-migration code publishes a log but no
|
||||||
|
// revision; it must not be silently ignored.
|
||||||
|
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
||||||
|
final remote = _FakeRemote({'ns/devices/phone/log.json': peer});
|
||||||
|
final store = InMemorySyncStateStore();
|
||||||
|
final merged = await _tick(remote, store);
|
||||||
|
expect(merged.keys, contains('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a peer that has nothing pushed yet', () async {
|
||||||
|
final remote = _FakeRemote({'ns/devices/phone/other.txt': 'x'});
|
||||||
|
final merged = await _tick(remote, InMemorySyncStateStore());
|
||||||
|
expect(merged, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never reads its own device back', () async {
|
||||||
|
final remote = _FakeRemote({
|
||||||
|
'ns/devices/pc/log.json': _encode(_log('a', '1')),
|
||||||
|
'ns/revs/pc': 'whatever',
|
||||||
|
});
|
||||||
|
await _tick(remote, InMemorySyncStateStore());
|
||||||
|
expect(remote.reads, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
193
crdt_sync_dart/test/sync_revisions_persistence_test.dart
Normal file
193
crdt_sync_dart/test/sync_revisions_persistence_test.dart
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/// Tests for the revision tracking that keeps sync inside the free tier.
|
||||||
|
///
|
||||||
|
/// Two savings, both measured against the GitHub-backed history this
|
||||||
|
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
|
||||||
|
/// re-downloaded every peer's whole log regardless of whether it had changed.
|
||||||
|
/// These tests assert the *request counts*, since that -- not the merge
|
||||||
|
/// result -- is what the free-tier headroom depends on.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
|
||||||
|
id: Record(
|
||||||
|
id: id,
|
||||||
|
fields: {
|
||||||
|
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _encode(Log log) =>
|
||||||
|
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
|
||||||
|
|
||||||
|
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
|
||||||
|
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// An in-memory [RemoteStore] that also serves revision maps in one read,
|
||||||
|
/// standing in for [FirebaseRestClient]. Counts every call so tests can
|
||||||
|
/// assert on traffic rather than on results.
|
||||||
|
class _FakeRemote implements RemoteStore, BulkMapReader {
|
||||||
|
_FakeRemote(this.files);
|
||||||
|
|
||||||
|
final Map<String, String> files;
|
||||||
|
final List<String> reads = [];
|
||||||
|
final List<String> writes = [];
|
||||||
|
final List<String> mapReads = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> listDirectory(String path) async => files.keys
|
||||||
|
.where((key) => key.startsWith('$path/'))
|
||||||
|
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getFileText(String path) async {
|
||||||
|
reads.add(path);
|
||||||
|
return files[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putFileText(
|
||||||
|
String path,
|
||||||
|
String text, {
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
writes.add(path);
|
||||||
|
files[path] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getStringMap(String path) async {
|
||||||
|
mapReads.add(path);
|
||||||
|
return {
|
||||||
|
for (final entry in files.entries)
|
||||||
|
if (entry.key.startsWith('$path/'))
|
||||||
|
entry.key.substring(path.length + 1): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||||
|
files.remove(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> canAccessRemote() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Log> _tick(
|
||||||
|
RemoteStore remote,
|
||||||
|
SyncStateStore? store, {
|
||||||
|
Log local = const {},
|
||||||
|
String deviceId = 'pc',
|
||||||
|
}) => syncLog(
|
||||||
|
client: remote,
|
||||||
|
deviceId: deviceId,
|
||||||
|
pathPrefix: 'ns/devices',
|
||||||
|
localLog: local,
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
stateStore: store,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PersistedSyncStateStore', () {
|
||||||
|
test('round-trips state through persistence', () async {
|
||||||
|
final persistence = _FakePersistence();
|
||||||
|
const state = SyncState(
|
||||||
|
pushedRev: 'rev-local',
|
||||||
|
peerRevs: {'phone': 'rev-phone'},
|
||||||
|
);
|
||||||
|
|
||||||
|
await PersistedSyncStateStore(persistence).save(state);
|
||||||
|
final loaded = await PersistedSyncStateStore(persistence).load();
|
||||||
|
|
||||||
|
// A *separate* store instance reads it back: that is the whole point --
|
||||||
|
// surviving the cold start that an in-memory store does not.
|
||||||
|
expect(loaded.pushedRev, 'rev-local');
|
||||||
|
expect(loaded.peerRevs, {'phone': 'rev-phone'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a cold start, so peers are not re-downloaded', () async {
|
||||||
|
// The free-tier claim on mobile rests on this: a fresh process must
|
||||||
|
// still skip an unchanged peer.
|
||||||
|
final persistence = _FakePersistence();
|
||||||
|
final remote = _FakeRemote({
|
||||||
|
'ns/devices/phone/log.json': _encode(_log('a', '1')),
|
||||||
|
'ns/revs/phone': revisionOf(_encode(_log('a', '1'))),
|
||||||
|
});
|
||||||
|
|
||||||
|
await _tick(remote, PersistedSyncStateStore(persistence));
|
||||||
|
remote.reads.clear();
|
||||||
|
await _tick(remote, PersistedSyncStateStore(persistence));
|
||||||
|
|
||||||
|
expect(remote.reads, isNot(contains('ns/devices/phone/log.json')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports nothing remembered when absent', () async {
|
||||||
|
final loaded = await PersistedSyncStateStore(_FakePersistence()).load();
|
||||||
|
|
||||||
|
expect(loaded.pushedRev, isNull);
|
||||||
|
expect(loaded.peerRevs, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degrades to remembering nothing when empty', () async {
|
||||||
|
final loaded = await PersistedSyncStateStore(
|
||||||
|
_FakePersistence(text: ''),
|
||||||
|
).load();
|
||||||
|
|
||||||
|
expect(loaded.pushedRev, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degrades to remembering nothing when corrupt', () async {
|
||||||
|
// Costs one tick of extra traffic rather than failing the sync.
|
||||||
|
final loaded = await PersistedSyncStateStore(
|
||||||
|
_FakePersistence(text: 'not json{'),
|
||||||
|
).load();
|
||||||
|
|
||||||
|
expect(loaded.pushedRev, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degrades to remembering nothing when not an object', () async {
|
||||||
|
final loaded = await PersistedSyncStateStore(
|
||||||
|
_FakePersistence(text: '["a list"]'),
|
||||||
|
).load();
|
||||||
|
|
||||||
|
expect(loaded.pushedRev, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degrades to remembering nothing when the read throws', () async {
|
||||||
|
// An unreadable file must not fail the tick.
|
||||||
|
final loaded = await PersistedSyncStateStore(
|
||||||
|
_FakePersistence(readThrows: true),
|
||||||
|
).load();
|
||||||
|
|
||||||
|
expect(loaded.pushedRev, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [LogPersistence] fake with injectable failure modes.
|
||||||
|
class _FakePersistence implements LogPersistence {
|
||||||
|
_FakePersistence({this.text, this.readThrows = false});
|
||||||
|
|
||||||
|
String? text;
|
||||||
|
final bool readThrows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read() async {
|
||||||
|
if (readThrows) throw const FormatException('unreadable');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String value) async => text = value;
|
||||||
|
}
|
||||||
@ -83,45 +83,6 @@ class _FakeRemote implements RemoteStore, BulkMapReader {
|
|||||||
void close() {}
|
void close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [RemoteStore] with no bulk-map capability, standing in for
|
|
||||||
/// [GitHubClient]: revision tracking must degrade, not break.
|
|
||||||
///
|
|
||||||
/// Delegates rather than extends, because a subclass of [_FakeRemote] would
|
|
||||||
/// inherit `BulkMapReader` -- the very capability this fake exists to lack.
|
|
||||||
class _FakeRemoteWithoutBulkRead implements RemoteStore {
|
|
||||||
_FakeRemoteWithoutBulkRead(Map<String, String> files)
|
|
||||||
: _inner = _FakeRemote(files);
|
|
||||||
|
|
||||||
final _FakeRemote _inner;
|
|
||||||
|
|
||||||
Map<String, String> get files => _inner.files;
|
|
||||||
List<String> get reads => _inner.reads;
|
|
||||||
List<String> get writes => _inner.writes;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> getFileText(String path) => _inner.getFileText(path);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> putFileText(
|
|
||||||
String path,
|
|
||||||
String text, {
|
|
||||||
required String message,
|
|
||||||
}) => _inner.putFileText(path, text, message: message);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
|
||||||
_inner.deleteFile(path, message: message);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void close() => _inner.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Log> _tick(
|
Future<Log> _tick(
|
||||||
RemoteStore remote,
|
RemoteStore remote,
|
||||||
SyncStateStore? store, {
|
SyncStateStore? store, {
|
||||||
@ -224,228 +185,4 @@ void main() {
|
|||||||
expect(remote.writes.where((w) => w.startsWith('ns/revs')), isEmpty);
|
expect(remote.writes.where((w) => w.startsWith('ns/revs')), isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('peer download suppression', () {
|
|
||||||
test('downloads a peer whose revision it has never seen', () async {
|
|
||||||
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
|
||||||
final remote = _FakeRemote({
|
|
||||||
'ns/devices/phone/log.json': peer,
|
|
||||||
'ns/revs/phone': revisionOf(peer),
|
|
||||||
});
|
|
||||||
final merged = await _tick(remote, InMemorySyncStateStore());
|
|
||||||
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
|
||||||
expect(merged.keys, contains('b'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips the download when the peer revision is unchanged', () async {
|
|
||||||
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
|
||||||
final remote = _FakeRemote({
|
|
||||||
'ns/devices/phone/log.json': peer,
|
|
||||||
'ns/revs/phone': revisionOf(peer),
|
|
||||||
});
|
|
||||||
final store = InMemorySyncStateStore();
|
|
||||||
final first = await _tick(remote, store);
|
|
||||||
remote.reads.clear();
|
|
||||||
|
|
||||||
// The peer's records are already in the local log from tick one, so
|
|
||||||
// re-downloading them is pure waste -- this is the ~700 MB/month that
|
|
||||||
// the revision check removes.
|
|
||||||
await _tick(remote, store, local: first);
|
|
||||||
expect(remote.reads, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('downloads again once the peer publishes a new revision', () async {
|
|
||||||
final peerV1 = _encode(_log('b', 'v1', nodeId: 'node-b'));
|
|
||||||
final remote = _FakeRemote({
|
|
||||||
'ns/devices/phone/log.json': peerV1,
|
|
||||||
'ns/revs/phone': revisionOf(peerV1),
|
|
||||||
});
|
|
||||||
final store = InMemorySyncStateStore();
|
|
||||||
final first = await _tick(remote, store);
|
|
||||||
remote.reads.clear();
|
|
||||||
|
|
||||||
final peerV2 = _encode({
|
|
||||||
..._decode(peerV1),
|
|
||||||
..._log('c', 'v2', nodeId: 'node-b'),
|
|
||||||
});
|
|
||||||
remote.files['ns/devices/phone/log.json'] = peerV2;
|
|
||||||
remote.files['ns/revs/phone'] = revisionOf(peerV2);
|
|
||||||
|
|
||||||
final second = await _tick(remote, store, local: first);
|
|
||||||
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
|
||||||
expect(second.keys, containsAll(['b', 'c']));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('re-downloads a peer whose push was corrupt', () async {
|
|
||||||
// A failed decode must not be remembered as seen, or the corruption
|
|
||||||
// would be permanent.
|
|
||||||
final remote = _FakeRemote({
|
|
||||||
'ns/devices/phone/log.json': 'not json at all',
|
|
||||||
'ns/revs/phone': revisionOf('not json at all'),
|
|
||||||
});
|
|
||||||
final store = InMemorySyncStateStore();
|
|
||||||
await _tick(remote, store);
|
|
||||||
remote.reads.clear();
|
|
||||||
|
|
||||||
await _tick(remote, store);
|
|
||||||
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('downloads when the peer has published no revision yet', () async {
|
|
||||||
// A device still running the pre-migration code publishes a log but no
|
|
||||||
// revision; it must not be silently ignored.
|
|
||||||
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
|
||||||
final remote = _FakeRemote({'ns/devices/phone/log.json': peer});
|
|
||||||
final store = InMemorySyncStateStore();
|
|
||||||
final merged = await _tick(remote, store);
|
|
||||||
expect(merged.keys, contains('b'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips a peer that has nothing pushed yet', () async {
|
|
||||||
final remote = _FakeRemote({'ns/devices/phone/other.txt': 'x'});
|
|
||||||
final merged = await _tick(remote, InMemorySyncStateStore());
|
|
||||||
expect(merged, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('never reads its own device back', () async {
|
|
||||||
final remote = _FakeRemote({
|
|
||||||
'ns/devices/pc/log.json': _encode(_log('a', '1')),
|
|
||||||
'ns/revs/pc': 'whatever',
|
|
||||||
});
|
|
||||||
await _tick(remote, InMemorySyncStateStore());
|
|
||||||
expect(remote.reads, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('backends without bulk-map reads', () {
|
|
||||||
test('still sync correctly, just without the saving', () async {
|
|
||||||
// GitHubClient has no BulkMapReader, so revision lookup degrades to
|
|
||||||
// "fetch everything" -- correctness must not depend on the optimisation.
|
|
||||||
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
|
|
||||||
final remote = _FakeRemoteWithoutBulkRead({
|
|
||||||
'ns/devices/phone/log.json': peer,
|
|
||||||
});
|
|
||||||
final store = InMemorySyncStateStore();
|
|
||||||
final first = await _tick(remote, store);
|
|
||||||
expect(first.keys, contains('b'));
|
|
||||||
|
|
||||||
remote.reads.clear();
|
|
||||||
await _tick(remote, store, local: first);
|
|
||||||
expect(remote.reads, contains('ns/devices/phone/log.json'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('revision publishing order', () {
|
|
||||||
test('publishes the log before its revision', () async {
|
|
||||||
// Reversed, a peer would cache "seen rev X" against a log it never
|
|
||||||
// received, and skip it forever.
|
|
||||||
final remote = _FakeRemote({});
|
|
||||||
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
|
|
||||||
expect(
|
|
||||||
remote.writes.indexOf('ns/devices/pc/log.json'),
|
|
||||||
lessThan(remote.writes.indexOf('ns/revs/pc')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('each device writes only its own revision key', () async {
|
|
||||||
// Per-device keys rather than one shared map: a whole-map write would
|
|
||||||
// erase every other device's entry, after which those peers would look
|
|
||||||
// permanently unchanged and never be fetched again.
|
|
||||||
final remote = _FakeRemote({'ns/revs/phone': 'peer-rev'});
|
|
||||||
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
|
|
||||||
expect(remote.files['ns/revs/phone'], 'peer-rev');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('PersistedSyncStateStore', () {
|
|
||||||
test('round-trips state through persistence', () async {
|
|
||||||
final persistence = _FakePersistence();
|
|
||||||
const state = SyncState(
|
|
||||||
pushedRev: 'rev-local',
|
|
||||||
peerRevs: {'phone': 'rev-phone'},
|
|
||||||
);
|
|
||||||
|
|
||||||
await PersistedSyncStateStore(persistence).save(state);
|
|
||||||
final loaded = await PersistedSyncStateStore(persistence).load();
|
|
||||||
|
|
||||||
// A *separate* store instance reads it back: that is the whole point --
|
|
||||||
// surviving the cold start that an in-memory store does not.
|
|
||||||
expect(loaded.pushedRev, 'rev-local');
|
|
||||||
expect(loaded.peerRevs, {'phone': 'rev-phone'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('survives a cold start, so peers are not re-downloaded', () async {
|
|
||||||
// The free-tier claim on mobile rests on this: a fresh process must
|
|
||||||
// still skip an unchanged peer.
|
|
||||||
final persistence = _FakePersistence();
|
|
||||||
final remote = _FakeRemote({
|
|
||||||
'ns/devices/phone/log.json': _encode(_log('a', '1')),
|
|
||||||
'ns/revs/phone': revisionOf(_encode(_log('a', '1'))),
|
|
||||||
});
|
|
||||||
|
|
||||||
await _tick(remote, PersistedSyncStateStore(persistence));
|
|
||||||
remote.reads.clear();
|
|
||||||
await _tick(remote, PersistedSyncStateStore(persistence));
|
|
||||||
|
|
||||||
expect(remote.reads, isNot(contains('ns/devices/phone/log.json')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reports nothing remembered when absent', () async {
|
|
||||||
final loaded = await PersistedSyncStateStore(_FakePersistence()).load();
|
|
||||||
|
|
||||||
expect(loaded.pushedRev, isNull);
|
|
||||||
expect(loaded.peerRevs, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('degrades to remembering nothing when empty', () async {
|
|
||||||
final loaded = await PersistedSyncStateStore(
|
|
||||||
_FakePersistence(text: ''),
|
|
||||||
).load();
|
|
||||||
|
|
||||||
expect(loaded.pushedRev, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('degrades to remembering nothing when corrupt', () async {
|
|
||||||
// Costs one tick of extra traffic rather than failing the sync.
|
|
||||||
final loaded = await PersistedSyncStateStore(
|
|
||||||
_FakePersistence(text: 'not json{'),
|
|
||||||
).load();
|
|
||||||
|
|
||||||
expect(loaded.pushedRev, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('degrades to remembering nothing when not an object', () async {
|
|
||||||
final loaded = await PersistedSyncStateStore(
|
|
||||||
_FakePersistence(text: '["a list"]'),
|
|
||||||
).load();
|
|
||||||
|
|
||||||
expect(loaded.pushedRev, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('degrades to remembering nothing when the read throws', () async {
|
|
||||||
// An unreadable file must not fail the tick.
|
|
||||||
final loaded = await PersistedSyncStateStore(
|
|
||||||
_FakePersistence(readThrows: true),
|
|
||||||
).load();
|
|
||||||
|
|
||||||
expect(loaded.pushedRev, isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A [LogPersistence] fake with injectable failure modes.
|
|
||||||
class _FakePersistence implements LogPersistence {
|
|
||||||
_FakePersistence({this.text, this.readThrows = false});
|
|
||||||
|
|
||||||
String? text;
|
|
||||||
final bool readThrows;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> read() async {
|
|
||||||
if (readThrows) throw const FormatException('unreadable');
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> write(String value) async => text = value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
175
crdt_sync_dart/test/sync_skips_test.dart
Normal file
175
crdt_sync_dart/test/sync_skips_test.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Hlc _make(int wallTimeMs, {int counter = 0, String nodeId = 'node-a'}) =>
|
||||||
|
Hlc(wallTimeMs: wallTimeMs, counter: counter, nodeId: nodeId);
|
||||||
|
|
||||||
|
String _encode(Log log) =>
|
||||||
|
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
|
||||||
|
|
||||||
|
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
|
||||||
|
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
http.Response _response(int statusCode, [Object? jsonBody]) =>
|
||||||
|
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
|
||||||
|
|
||||||
|
class _PutCall {
|
||||||
|
_PutCall(this.path, this.body);
|
||||||
|
final String path;
|
||||||
|
final Map<String, dynamic> body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a [GitHubClient] backed by an in-memory router: GET
|
||||||
|
/// `.../contents/<key>` returns `contentResponses[key]` (404 if absent), the
|
||||||
|
/// bare repo-existence GET always succeeds, and every PUT is recorded into
|
||||||
|
/// [putCalls] and answered with 200.
|
||||||
|
({GitHubClient client, List<_PutCall> putCalls}) _client({
|
||||||
|
Map<String, http.Response> contentResponses = const {},
|
||||||
|
}) {
|
||||||
|
final putCalls = <_PutCall>[];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
final path = request.url.path;
|
||||||
|
if (!path.contains('/contents/')) {
|
||||||
|
return _response(200);
|
||||||
|
}
|
||||||
|
final key = path.split('/contents/').last;
|
||||||
|
if (request.method == 'PUT') {
|
||||||
|
putCalls.add(
|
||||||
|
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
return _response(200);
|
||||||
|
}
|
||||||
|
return contentResponses[key] ?? _response(404);
|
||||||
|
});
|
||||||
|
final client = GitHubClient(
|
||||||
|
owner: 'kuhyx',
|
||||||
|
repo: 'crdt-sync-demo',
|
||||||
|
token: 'fake-token',
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
return (client: client, putCalls: putCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response _directoryOf(List<String> deviceIds) =>
|
||||||
|
_response(200, deviceIds.map((id) => {'name': id, 'type': 'dir'}).toList());
|
||||||
|
|
||||||
|
http.Response _fileContaining(String text) =>
|
||||||
|
_response(200, {'content': base64.encode(utf8.encode(text))});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('syncLog skips unusable peer files', () {
|
||||||
|
test('skips a device with no pushed file yet', () async {
|
||||||
|
final (:client, :putCalls) = _client(
|
||||||
|
contentResponses: {
|
||||||
|
'devices': _directoryOf(['phone']),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final merged = await syncLog(
|
||||||
|
client: client,
|
||||||
|
deviceId: 'pc',
|
||||||
|
pathPrefix: 'devices',
|
||||||
|
localLog: {},
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged, isEmpty);
|
||||||
|
expect(putCalls.single.body['content'], base64.encode(utf8.encode('{}')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a device whose pushed file is corrupt', () async {
|
||||||
|
final (:client, :putCalls) = _client(
|
||||||
|
contentResponses: {
|
||||||
|
'devices': _directoryOf(['phone']),
|
||||||
|
'devices/phone/log.json': _fileContaining('{not valid json'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final merged = await syncLog(
|
||||||
|
client: client,
|
||||||
|
deviceId: 'pc',
|
||||||
|
pathPrefix: 'devices',
|
||||||
|
localLog: {},
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged, isEmpty);
|
||||||
|
expect(putCalls, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a device whose pushed JSON has the wrong shape', () async {
|
||||||
|
// Valid JSON that isn't a record map (e.g. from an incompatible
|
||||||
|
// writer) must be skipped like corrupt JSON, not crash the whole
|
||||||
|
// sync -- this is what the `on TypeError` catch in `syncLog` is
|
||||||
|
// for, not just JSON syntax errors caught by `FormatException`.
|
||||||
|
final (:client, :putCalls) = _client(
|
||||||
|
contentResponses: {
|
||||||
|
'devices': _directoryOf(['phone']),
|
||||||
|
'devices/phone/log.json': _fileContaining('{"a": 5}'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final merged = await syncLog(
|
||||||
|
client: client,
|
||||||
|
deviceId: 'pc',
|
||||||
|
pathPrefix: 'devices',
|
||||||
|
localLog: {},
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged, isEmpty);
|
||||||
|
expect(putCalls, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merges in a remote device\'s entries', () async {
|
||||||
|
final remoteLog = <String, Record>{
|
||||||
|
'b': Record(id: 'b', fields: {'text': ('from phone', _make(100))}),
|
||||||
|
};
|
||||||
|
final (:client, :putCalls) = _client(
|
||||||
|
contentResponses: {
|
||||||
|
'devices': _directoryOf(['phone']),
|
||||||
|
'devices/phone/log.json': _fileContaining(_encode(remoteLog)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final merged = await syncLog(
|
||||||
|
client: client,
|
||||||
|
deviceId: 'pc',
|
||||||
|
pathPrefix: 'devices',
|
||||||
|
localLog: {},
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged, equals(remoteLog));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses a custom filename and commit message', () async {
|
||||||
|
final (:client, :putCalls) = _client(
|
||||||
|
contentResponses: {'devices': _directoryOf([])},
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncLog(
|
||||||
|
client: client,
|
||||||
|
deviceId: 'pc',
|
||||||
|
pathPrefix: 'devices',
|
||||||
|
localLog: {},
|
||||||
|
encode: _encode,
|
||||||
|
decode: _decode,
|
||||||
|
filename: 'notes.json',
|
||||||
|
commitMessage: 'custom message',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(putCalls.single.path, 'devices/pc/notes.json');
|
||||||
|
expect(putCalls.single.body['message'], 'custom message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -156,114 +156,5 @@ void main() {
|
|||||||
|
|
||||||
expect(merged.keys, contains('stale'));
|
expect(merged.keys, contains('stale'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips a device with no pushed file yet', () async {
|
|
||||||
final (:client, :putCalls) = _client(
|
|
||||||
contentResponses: {
|
|
||||||
'devices': _directoryOf(['phone']),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final merged = await syncLog(
|
|
||||||
client: client,
|
|
||||||
deviceId: 'pc',
|
|
||||||
pathPrefix: 'devices',
|
|
||||||
localLog: {},
|
|
||||||
encode: _encode,
|
|
||||||
decode: _decode,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged, isEmpty);
|
|
||||||
expect(putCalls.single.body['content'], base64.encode(utf8.encode('{}')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips a device whose pushed file is corrupt', () async {
|
|
||||||
final (:client, :putCalls) = _client(
|
|
||||||
contentResponses: {
|
|
||||||
'devices': _directoryOf(['phone']),
|
|
||||||
'devices/phone/log.json': _fileContaining('{not valid json'),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final merged = await syncLog(
|
|
||||||
client: client,
|
|
||||||
deviceId: 'pc',
|
|
||||||
pathPrefix: 'devices',
|
|
||||||
localLog: {},
|
|
||||||
encode: _encode,
|
|
||||||
decode: _decode,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged, isEmpty);
|
|
||||||
expect(putCalls, hasLength(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips a device whose pushed JSON has the wrong shape', () async {
|
|
||||||
// Valid JSON that isn't a record map (e.g. from an incompatible
|
|
||||||
// writer) must be skipped like corrupt JSON, not crash the whole
|
|
||||||
// sync -- this is what the `on TypeError` catch in `syncLog` is
|
|
||||||
// for, not just JSON syntax errors caught by `FormatException`.
|
|
||||||
final (:client, :putCalls) = _client(
|
|
||||||
contentResponses: {
|
|
||||||
'devices': _directoryOf(['phone']),
|
|
||||||
'devices/phone/log.json': _fileContaining('{"a": 5}'),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final merged = await syncLog(
|
|
||||||
client: client,
|
|
||||||
deviceId: 'pc',
|
|
||||||
pathPrefix: 'devices',
|
|
||||||
localLog: {},
|
|
||||||
encode: _encode,
|
|
||||||
decode: _decode,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged, isEmpty);
|
|
||||||
expect(putCalls, hasLength(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('merges in a remote device\'s entries', () async {
|
|
||||||
final remoteLog = <String, Record>{
|
|
||||||
'b': Record(id: 'b', fields: {'text': ('from phone', _make(100))}),
|
|
||||||
};
|
|
||||||
final (:client, :putCalls) = _client(
|
|
||||||
contentResponses: {
|
|
||||||
'devices': _directoryOf(['phone']),
|
|
||||||
'devices/phone/log.json': _fileContaining(_encode(remoteLog)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final merged = await syncLog(
|
|
||||||
client: client,
|
|
||||||
deviceId: 'pc',
|
|
||||||
pathPrefix: 'devices',
|
|
||||||
localLog: {},
|
|
||||||
encode: _encode,
|
|
||||||
decode: _decode,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged, equals(remoteLog));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses a custom filename and commit message', () async {
|
|
||||||
final (:client, :putCalls) = _client(
|
|
||||||
contentResponses: {'devices': _directoryOf([])},
|
|
||||||
);
|
|
||||||
|
|
||||||
await syncLog(
|
|
||||||
client: client,
|
|
||||||
deviceId: 'pc',
|
|
||||||
pathPrefix: 'devices',
|
|
||||||
localLog: {},
|
|
||||||
encode: _encode,
|
|
||||||
decode: _decode,
|
|
||||||
filename: 'notes.json',
|
|
||||||
commitMessage: 'custom message',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(putCalls.single.path, 'devices/pc/notes.json');
|
|
||||||
expect(putCalls.single.body['message'], 'custom message');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
204
github_device_auth/test/device_auth_poll_test.dart
Normal file
204
github_device_auth/test/device_auth_poll_test.dart
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:github_device_auth/github_device_auth.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
/// Never actually waits: the poll loop's pacing is injected so a test that
|
||||||
|
/// exercises `slow_down` does not take 15 real seconds.
|
||||||
|
Future<void> noDelay(Duration _) async {}
|
||||||
|
|
||||||
|
GitHubDeviceAuth authWith(
|
||||||
|
MockClient client, {
|
||||||
|
String scope = 'repo',
|
||||||
|
}) => GitHubDeviceAuth(
|
||||||
|
clientId: 'cid',
|
||||||
|
scope: scope,
|
||||||
|
httpClient: client,
|
||||||
|
delay: noDelay,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('pollForToken', () {
|
||||||
|
const device = DeviceCodeResponse(
|
||||||
|
deviceCode: 'dc',
|
||||||
|
userCode: 'UC',
|
||||||
|
verificationUri: 'u',
|
||||||
|
interval: 1,
|
||||||
|
expiresIn: 60,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('returns the token once the user authorizes', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient((_) async {
|
||||||
|
calls += 1;
|
||||||
|
// First poll pending, second succeeds -- the normal path.
|
||||||
|
return http.Response(
|
||||||
|
calls == 1
|
||||||
|
? jsonEncode({'error': 'authorization_pending'})
|
||||||
|
: jsonEncode({'access_token': 'tok'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await auth.pollForToken(device), 'tok');
|
||||||
|
expect(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('obeys slow_down by taking GitHub s new interval', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient((_) async {
|
||||||
|
calls += 1;
|
||||||
|
return http.Response(
|
||||||
|
calls == 1
|
||||||
|
? jsonEncode({'error': 'slow_down', 'interval': 30})
|
||||||
|
: jsonEncode({'access_token': 'tok'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await auth.pollForToken(device), 'tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slow_down without an interval still backs off', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient((_) async {
|
||||||
|
calls += 1;
|
||||||
|
return http.Response(
|
||||||
|
calls == 1
|
||||||
|
? jsonEncode({'error': 'slow_down'})
|
||||||
|
: jsonEncode({'access_token': 'tok'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await auth.pollForToken(device), 'tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on a terminal error', () async {
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient(
|
||||||
|
(_) async => http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'error': 'access_denied',
|
||||||
|
'error_description': 'user said no',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
auth.pollForToken(device),
|
||||||
|
throwsA(
|
||||||
|
isA<DeviceAuthException>()
|
||||||
|
.having((e) => e.code, 'code', 'access_denied')
|
||||||
|
.having((e) => e.message, 'message', 'user said no'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the code when no description is given', () async {
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient(
|
||||||
|
(_) async => http.Response(jsonEncode({'error': 'expired'}), 200),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
auth.pollForToken(device),
|
||||||
|
throwsA(
|
||||||
|
isA<DeviceAuthException>().having((e) => e.message, 'msg', 'expired'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on a response with neither a token nor an error', () async {
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient((_) async => http.Response(jsonEncode({'x': 1}), 200)),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
auth.pollForToken(device),
|
||||||
|
throwsA(
|
||||||
|
isA<DeviceAuthException>().having((e) => e.code, 'code', 'unknown'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries a transient network failure instead of giving up', () async {
|
||||||
|
// GitHub can close the connection at the moment the user authorizes,
|
||||||
|
// and a phone can drop its network mid-poll. Losing an approved grant
|
||||||
|
// to a socket blip is the bug this retry exists to prevent.
|
||||||
|
var calls = 0;
|
||||||
|
final auth = authWith(
|
||||||
|
MockClient((_) async {
|
||||||
|
calls += 1;
|
||||||
|
if (calls == 1) {
|
||||||
|
throw http.ClientException('Connection closed before full header');
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await auth.pollForToken(device), 'tok');
|
||||||
|
expect(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gives up once the device code has expired', () async {
|
||||||
|
const expired = DeviceCodeResponse(
|
||||||
|
deviceCode: 'dc',
|
||||||
|
userCode: 'UC',
|
||||||
|
verificationUri: 'u',
|
||||||
|
interval: 1,
|
||||||
|
// Already in the past, so the loop body never runs.
|
||||||
|
expiresIn: -1,
|
||||||
|
);
|
||||||
|
final auth = authWith(MockClient((_) async => http.Response('{}', 200)));
|
||||||
|
await expectLater(
|
||||||
|
auth.pollForToken(expired),
|
||||||
|
throwsA(
|
||||||
|
isA<DeviceAuthException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
'expired_token',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close() is safe to call', () {
|
||||||
|
authWith(MockClient((_) async => http.Response('{}', 200))).close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructs with a real http client and default scope', () {
|
||||||
|
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
|
||||||
|
expect(auth.scope, 'repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polls the overridden token endpoint', () async {
|
||||||
|
late Uri seen;
|
||||||
|
final auth = GitHubDeviceAuth(
|
||||||
|
clientId: 'cid',
|
||||||
|
tokenUrl: 'http://127.0.0.1:9/gh/auth/device/poll',
|
||||||
|
httpClient: MockClient((req) async {
|
||||||
|
seen = req.url;
|
||||||
|
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
|
||||||
|
}),
|
||||||
|
delay: noDelay,
|
||||||
|
);
|
||||||
|
|
||||||
|
await auth.pollForToken(
|
||||||
|
const DeviceCodeResponse(
|
||||||
|
deviceCode: 'dc',
|
||||||
|
userCode: 'UC',
|
||||||
|
verificationUri: 'u',
|
||||||
|
interval: 1,
|
||||||
|
expiresIn: 60,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(seen.toString(), 'http://127.0.0.1:9/gh/auth/device/poll');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -125,183 +125,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('pollForToken', () {
|
|
||||||
const device = DeviceCodeResponse(
|
|
||||||
deviceCode: 'dc',
|
|
||||||
userCode: 'UC',
|
|
||||||
verificationUri: 'u',
|
|
||||||
interval: 1,
|
|
||||||
expiresIn: 60,
|
|
||||||
);
|
|
||||||
|
|
||||||
test('returns the token once the user authorizes', () async {
|
|
||||||
var calls = 0;
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient((_) async {
|
|
||||||
calls += 1;
|
|
||||||
// First poll pending, second succeeds -- the normal path.
|
|
||||||
return http.Response(
|
|
||||||
calls == 1
|
|
||||||
? jsonEncode({'error': 'authorization_pending'})
|
|
||||||
: jsonEncode({'access_token': 'tok'}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(await auth.pollForToken(device), 'tok');
|
|
||||||
expect(calls, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('obeys slow_down by taking GitHub s new interval', () async {
|
|
||||||
var calls = 0;
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient((_) async {
|
|
||||||
calls += 1;
|
|
||||||
return http.Response(
|
|
||||||
calls == 1
|
|
||||||
? jsonEncode({'error': 'slow_down', 'interval': 30})
|
|
||||||
: jsonEncode({'access_token': 'tok'}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(await auth.pollForToken(device), 'tok');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('slow_down without an interval still backs off', () async {
|
|
||||||
var calls = 0;
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient((_) async {
|
|
||||||
calls += 1;
|
|
||||||
return http.Response(
|
|
||||||
calls == 1
|
|
||||||
? jsonEncode({'error': 'slow_down'})
|
|
||||||
: jsonEncode({'access_token': 'tok'}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(await auth.pollForToken(device), 'tok');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on a terminal error', () async {
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient(
|
|
||||||
(_) async => http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'error': 'access_denied',
|
|
||||||
'error_description': 'user said no',
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expectLater(
|
|
||||||
auth.pollForToken(device),
|
|
||||||
throwsA(
|
|
||||||
isA<DeviceAuthException>()
|
|
||||||
.having((e) => e.code, 'code', 'access_denied')
|
|
||||||
.having((e) => e.message, 'message', 'user said no'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to the code when no description is given', () async {
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient(
|
|
||||||
(_) async => http.Response(jsonEncode({'error': 'expired'}), 200),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expectLater(
|
|
||||||
auth.pollForToken(device),
|
|
||||||
throwsA(
|
|
||||||
isA<DeviceAuthException>().having((e) => e.message, 'msg', 'expired'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on a response with neither a token nor an error', () async {
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient((_) async => http.Response(jsonEncode({'x': 1}), 200)),
|
|
||||||
);
|
|
||||||
await expectLater(
|
|
||||||
auth.pollForToken(device),
|
|
||||||
throwsA(
|
|
||||||
isA<DeviceAuthException>().having((e) => e.code, 'code', 'unknown'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('retries a transient network failure instead of giving up', () async {
|
|
||||||
// GitHub can close the connection at the moment the user authorizes,
|
|
||||||
// and a phone can drop its network mid-poll. Losing an approved grant
|
|
||||||
// to a socket blip is the bug this retry exists to prevent.
|
|
||||||
var calls = 0;
|
|
||||||
final auth = authWith(
|
|
||||||
MockClient((_) async {
|
|
||||||
calls += 1;
|
|
||||||
if (calls == 1) {
|
|
||||||
throw http.ClientException('Connection closed before full header');
|
|
||||||
}
|
|
||||||
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(await auth.pollForToken(device), 'tok');
|
|
||||||
expect(calls, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gives up once the device code has expired', () async {
|
|
||||||
const expired = DeviceCodeResponse(
|
|
||||||
deviceCode: 'dc',
|
|
||||||
userCode: 'UC',
|
|
||||||
verificationUri: 'u',
|
|
||||||
interval: 1,
|
|
||||||
// Already in the past, so the loop body never runs.
|
|
||||||
expiresIn: -1,
|
|
||||||
);
|
|
||||||
final auth = authWith(MockClient((_) async => http.Response('{}', 200)));
|
|
||||||
await expectLater(
|
|
||||||
auth.pollForToken(expired),
|
|
||||||
throwsA(
|
|
||||||
isA<DeviceAuthException>()
|
|
||||||
.having((e) => e.code, 'code', 'expired_token'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('close() is safe to call', () {
|
|
||||||
authWith(MockClient((_) async => http.Response('{}', 200))).close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('constructs with a real http client and default scope', () {
|
|
||||||
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
|
|
||||||
expect(auth.scope, 'repo');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('polls the overridden token endpoint', () async {
|
|
||||||
late Uri seen;
|
|
||||||
final auth = GitHubDeviceAuth(
|
|
||||||
clientId: 'cid',
|
|
||||||
tokenUrl: 'http://127.0.0.1:9/gh/auth/device/poll',
|
|
||||||
httpClient: MockClient((req) async {
|
|
||||||
seen = req.url;
|
|
||||||
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
|
|
||||||
}),
|
|
||||||
delay: noDelay,
|
|
||||||
);
|
|
||||||
|
|
||||||
await auth.pollForToken(
|
|
||||||
const DeviceCodeResponse(
|
|
||||||
deviceCode: 'dc',
|
|
||||||
userCode: 'UC',
|
|
||||||
verificationUri: 'u',
|
|
||||||
interval: 1,
|
|
||||||
expiresIn: 60,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(seen.toString(), 'http://127.0.0.1:9/gh/auth/device/poll');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
143
sync_settings_ui/test/sync_settings_screen_backup_test.dart
Normal file
143
sync_settings_ui/test/sync_settings_screen_backup_test.dart
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||||
|
|
||||||
|
FirebaseRestClient _fakeClient() => FirebaseRestClient(
|
||||||
|
databaseUrl: 'https://example.firebasedatabase.app',
|
||||||
|
auth: FirebaseTokenProvider(
|
||||||
|
apiKey: 'test-key',
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _wrap(Widget child) => MaterialApp(home: child);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Backup section', () {
|
||||||
|
testWidgets('omitted entirely when backup is null', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Backup'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('export and import call the injected callbacks', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var exported = false;
|
||||||
|
var imported = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
backup: BackupSlot(
|
||||||
|
label: 'notes',
|
||||||
|
export: () async => exported = true,
|
||||||
|
import: () async => imported = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Backup'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Export notes'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(exported, isTrue);
|
||||||
|
expect(find.text('Exported notes.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Import notes'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(imported, isTrue);
|
||||||
|
expect(find.text('Imported notes.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('export failure reports the error', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
backup: BackupSlot(
|
||||||
|
label: 'notes',
|
||||||
|
export: () async => throw Exception('disk full'),
|
||||||
|
import: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Export notes'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Export failed'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('import failure reports the error', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
backup: BackupSlot(
|
||||||
|
label: 'notes',
|
||||||
|
export: () async {},
|
||||||
|
import: () async => throw Exception('corrupt file'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Import notes'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Import failed'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows a loading indicator until initial load completes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<FirebaseAccount?>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () => completer.future,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
completer.complete(null);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
145
sync_settings_ui/test/sync_settings_screen_google_test.dart
Normal file
145
sync_settings_ui/test/sync_settings_screen_google_test.dart
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||||
|
|
||||||
|
FirebaseRestClient _fakeClient() => FirebaseRestClient(
|
||||||
|
databaseUrl: 'https://example.firebasedatabase.app',
|
||||||
|
auth: FirebaseTokenProvider(
|
||||||
|
apiKey: 'test-key',
|
||||||
|
store: InMemoryCredentialStore(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _wrap(Widget child) => MaterialApp(home: child);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Firebase section Google sign-in', () {
|
||||||
|
testWidgets('Google sign-in cancelled shows the cancellation message', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
googleFirebaseFactory: () async => null,
|
||||||
|
googleAvailable: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with Google'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Google sign-in was cancelled.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Google sign-in that does not persist a session reports the retry '
|
||||||
|
'message',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
googleFirebaseFactory: () async => _fakeClient(),
|
||||||
|
googleAvailable: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with Google'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining('did not save the session'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('Google sign-in wrong-account error surfaces the message', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
googleFirebaseFactory: () async =>
|
||||||
|
throw FirebaseAuthError('wrong uid'),
|
||||||
|
googleAvailable: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with Google'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('wrong uid'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Google sign-in generic failure surfaces the error text', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
googleFirebaseFactory: () async => throw StateError('boom'),
|
||||||
|
googleAvailable: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with Google'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Google sign-in failed'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'hides the Google button when googleAvailable is true but no '
|
||||||
|
'googleFirebaseFactory is wired -- a visible button that can never '
|
||||||
|
'succeed must not render',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(
|
||||||
|
SyncSettingsScreen(
|
||||||
|
accountLoader: () async => null,
|
||||||
|
accountSaver: (_) async {},
|
||||||
|
accountClearer: () async {},
|
||||||
|
sessionProbe: () async => false,
|
||||||
|
firebaseFactory: () async => null,
|
||||||
|
googleAvailable: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Sign in with Google'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -203,255 +203,5 @@ void main() {
|
|||||||
expect(find.text('Connected to Firebase.'), findsOneWidget);
|
expect(find.text('Connected to Firebase.'), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('Google sign-in cancelled shows the cancellation message', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
googleFirebaseFactory: () async => null,
|
|
||||||
googleAvailable: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Google sign-in was cancelled.'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'Google sign-in that does not persist a session reports the retry '
|
|
||||||
'message',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
googleFirebaseFactory: () async => _fakeClient(),
|
|
||||||
googleAvailable: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.textContaining('did not save the session'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets('Google sign-in wrong-account error surfaces the message', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
googleFirebaseFactory: () async =>
|
|
||||||
throw FirebaseAuthError('wrong uid'),
|
|
||||||
googleAvailable: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('wrong uid'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Google sign-in generic failure surfaces the error text', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
googleFirebaseFactory: () async => throw StateError('boom'),
|
|
||||||
googleAvailable: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Sign in with Google'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('Google sign-in failed'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'hides the Google button when googleAvailable is true but no '
|
|
||||||
'googleFirebaseFactory is wired -- a visible button that can never '
|
|
||||||
'succeed must not render',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
googleAvailable: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Sign in with Google'), findsNothing);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('Backup section', () {
|
|
||||||
testWidgets('omitted entirely when backup is null', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Backup'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('export and import call the injected callbacks', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
var exported = false;
|
|
||||||
var imported = false;
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
backup: BackupSlot(
|
|
||||||
label: 'notes',
|
|
||||||
export: () async => exported = true,
|
|
||||||
import: () async => imported = true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Backup'), findsOneWidget);
|
|
||||||
await tester.tap(find.text('Export notes'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(exported, isTrue);
|
|
||||||
expect(find.text('Exported notes.'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Import notes'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(imported, isTrue);
|
|
||||||
expect(find.text('Imported notes.'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('export failure reports the error', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
backup: BackupSlot(
|
|
||||||
label: 'notes',
|
|
||||||
export: () async => throw Exception('disk full'),
|
|
||||||
import: () async {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Export notes'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.textContaining('Export failed'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('import failure reports the error', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () async => null,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
backup: BackupSlot(
|
|
||||||
label: 'notes',
|
|
||||||
export: () async {},
|
|
||||||
import: () async => throw Exception('corrupt file'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Import notes'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.textContaining('Import failed'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows a loading indicator until initial load completes', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final completer = Completer<FirebaseAccount?>();
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
SyncSettingsScreen(
|
|
||||||
accountLoader: () => completer.future,
|
|
||||||
accountSaver: (_) async {},
|
|
||||||
accountClearer: () async {},
|
|
||||||
sessionProbe: () async => false,
|
|
||||||
firebaseFactory: () async => null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
||||||
|
|
||||||
completer.complete(null);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user