mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +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: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(jsonEncode(body), statusCode);
|
||||
@ -31,10 +32,7 @@ FirebaseTokenProvider _auth() => FirebaseTokenProvider(
|
||||
/// decoded result.
|
||||
late List<http.Request> requests;
|
||||
|
||||
FirebaseRestClient _client(
|
||||
Object response, {
|
||||
FirebaseTokenProvider? auth,
|
||||
}) {
|
||||
FirebaseRestClient _client(Object response, {FirebaseTokenProvider? auth}) {
|
||||
requests = [];
|
||||
final mock = http_testing.MockClient((request) async {
|
||||
requests.add(request);
|
||||
@ -58,8 +56,10 @@ void main() {
|
||||
|
||||
test('leaves dot-free device ids untouched', () {
|
||||
expect(encodeKey('pc'), 'pc');
|
||||
expect(encodeKey('32c28cf3-c0eb-423f-8bac-9bda9f158054'),
|
||||
'32c28cf3-c0eb-423f-8bac-9bda9f158054');
|
||||
expect(
|
||||
encodeKey('32c28cf3-c0eb-423f-8bac-9bda9f158054'),
|
||||
'32c28cf3-c0eb-423f-8bac-9bda9f158054',
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
final client = _client(_json(200, '{}'));
|
||||
await client.getFileText('ns/devices/pc/log.json');
|
||||
expect(
|
||||
requests.single.url.path,
|
||||
'/ns/devices/pc/log~2Ejson.json',
|
||||
);
|
||||
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
|
||||
expect(requests.single.url.queryParameters['auth'], 'id-token');
|
||||
});
|
||||
|
||||
@ -125,13 +122,16 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('raises DatabaseNotFoundError when the rules reject the uid', () async {
|
||||
final client = _client(_json(401, {'error': 'Permission denied'}));
|
||||
expect(
|
||||
() => client.getFileText('ns/devices/pc/log.json'),
|
||||
throwsA(isA<DatabaseNotFoundError>()),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'raises DatabaseNotFoundError when the rules reject the uid',
|
||||
() async {
|
||||
final client = _client(_json(401, {'error': 'Permission denied'}));
|
||||
expect(
|
||||
() => client.getFileText('ns/devices/pc/log.json'),
|
||||
throwsA(isA<DatabaseNotFoundError>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('raises DatabaseNotFoundError on 403 too', () async {
|
||||
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 '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 = {
|
||||
@ -21,31 +19,6 @@ const _valid = {
|
||||
'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('FirebaseConfig', () {
|
||||
test('parses a valid config', () {
|
||||
@ -141,220 +114,4 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@ -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() {}
|
||||
}
|
||||
|
||||
/// 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, {
|
||||
@ -224,228 +185,4 @@ void main() {
|
||||
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'));
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
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