diff --git a/crdt_sync_dart/test/firebase_auth_rest_google_test.dart b/crdt_sync_dart/test/firebase_auth_rest_google_test.dart new file mode 100644 index 0000000..0558382 --- /dev/null +++ b/crdt_sync_dart/test/firebase_auth_rest_google_test.dart @@ -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 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; + 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().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()), + ); + 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); + }); + }); +} diff --git a/crdt_sync_dart/test/firebase_auth_rest_test.dart b/crdt_sync_dart/test/firebase_auth_rest_test.dart index 1927ee0..63d46a6 100644 --- a/crdt_sync_dart/test/firebase_auth_rest_test.dart +++ b/crdt_sync_dart/test/firebase_auth_rest_test.dart @@ -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; - 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().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()), - ); - 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().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().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().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())); - }); - - 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())); - 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())); - 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())); - expect(await provider.hasSession(), isTrue); - }); - }); } diff --git a/crdt_sync_dart/test/firebase_auth_rest_tokens_test.dart b/crdt_sync_dart/test/firebase_auth_rest_tokens_test.dart new file mode 100644 index 0000000..906fb8d --- /dev/null +++ b/crdt_sync_dart/test/firebase_auth_rest_tokens_test.dart @@ -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 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().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().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())); + }); + + 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())); + 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())); + 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())); + expect(await provider.hasSession(), isTrue); + }); + }); +} diff --git a/crdt_sync_dart/test/firebase_auth_rest_uid_test.dart b/crdt_sync_dart/test/firebase_auth_rest_uid_test.dart new file mode 100644 index 0000000..fe611e7 --- /dev/null +++ b/crdt_sync_dart/test/firebase_auth_rest_uid_test.dart @@ -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 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().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'); + }); + }); +} diff --git a/crdt_sync_dart/test/firebase_client_test.dart b/crdt_sync_dart/test/firebase_client_test.dart index 67547bd..88d09ca 100644 --- a/crdt_sync_dart/test/firebase_client_test.dart +++ b/crdt_sync_dart/test/firebase_client_test.dart @@ -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 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()), - ); - }); + 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()), + ); + }, + ); 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()), - ); - }); - - 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()), - ); - }); - }); - - 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()), - ); - }); - - test('turns a network failure into FirebaseSyncError', () async { - final client = _client(http.ClientException('offline')); - expect( - () => client.patchValues('ns/revs', {'pc': 'abc'}), - throwsA(isA()), - ); - }); - }); - - 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()), - ); - }); - }); - - 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()), - ); - }); - - test('turns a network failure into FirebaseSyncError', () async { - final client = _client(http.ClientException('offline')); - expect( - () => client.deleteFile('ns/devices/pc/log.json'), - throwsA(isA()), - ); - }); - }); - - 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()); - }); - - test('DatabaseNotFoundError is catchable as either error type', () { - final error = DatabaseNotFoundError('gone'); - expect(error, isA()); - expect(error, isA()); - expect(error, isA()); - }); - - 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); - }); - }); } diff --git a/crdt_sync_dart/test/firebase_client_writes_test.dart b/crdt_sync_dart/test/firebase_client_writes_test.dart new file mode 100644 index 0000000..4dd78c4 --- /dev/null +++ b/crdt_sync_dart/test/firebase_client_writes_test.dart @@ -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 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()), + ); + }); + + 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()), + ); + }); + }); + + 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()), + ); + }); + + test('turns a network failure into FirebaseSyncError', () async { + final client = _client(http.ClientException('offline')); + expect( + () => client.patchValues('ns/revs', {'pc': 'abc'}), + throwsA(isA()), + ); + }); + }); + + 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()), + ); + }); + }); + + 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()), + ); + }); + + test('turns a network failure into FirebaseSyncError', () async { + final client = _client(http.ClientException('offline')); + expect( + () => client.deleteFile('ns/devices/pc/log.json'), + throwsA(isA()), + ); + }); + }); + + 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()); + }); + + test('DatabaseNotFoundError is catchable as either error type', () { + final error = DatabaseNotFoundError('gone'); + expect(error, isA()); + expect(error, isA()); + expect(error, isA()); + }); + + 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); + }); + }); +} diff --git a/crdt_sync_dart/test/firebase_config_client_google_test.dart b/crdt_sync_dart/test/firebase_config_client_google_test.dart new file mode 100644 index 0000000..50ad2e9 --- /dev/null +++ b/crdt_sync_dart/test/firebase_config_client_google_test.dart @@ -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> listDirectory(String path) async => []; + + @override + Future getFileText(String path) async => null; + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async {} + + @override + Future deleteFile(String path, {String message = ''}) async {} + + @override + Future 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()), + ); + 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 = []; + 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()), + ); + }); + + 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()); + 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()); + expect(mirror.mirror, same(github)); + mirror.close(); + }, + ); + }); +} diff --git a/crdt_sync_dart/test/firebase_config_client_test.dart b/crdt_sync_dart/test/firebase_config_client_test.dart new file mode 100644 index 0000000..d609df9 --- /dev/null +++ b/crdt_sync_dart/test/firebase_config_client_test.dart @@ -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()), + ); + }); + + 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 = []; + 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 = []; + 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 = []; + 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(); + }); + }); +} diff --git a/crdt_sync_dart/test/firebase_config_test.dart b/crdt_sync_dart/test/firebase_config_test.dart index daf1aa7..aad7d3f 100644 --- a/crdt_sync_dart/test/firebase_config_test.dart +++ b/crdt_sync_dart/test/firebase_config_test.dart @@ -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> listDirectory(String path) async => []; - - @override - Future getFileText(String path) async => null; - - @override - Future putFileText( - String path, - String text, { - required String message, - }) async {} - - @override - Future deleteFile(String path, {String message = ''}) async {} - - @override - Future 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()), - ); - }); - - 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 = []; - 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 = []; - 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 = []; - 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()), - ); - 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 = []; - 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()), - ); - }); - - 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()); - 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()); - expect(mirror.mirror, same(github)); - mirror.close(); - }, - ); - }); } diff --git a/crdt_sync_dart/test/github_client_test.dart b/crdt_sync_dart/test/github_client_test.dart index 8770ae1..eb3cf8a 100644 --- a/crdt_sync_dart/test/github_client_test.dart +++ b/crdt_sync_dart/test/github_client_test.dart @@ -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()), - ); - }, - ); - - 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()), - ); - }); - - 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()), - ); - }); - - 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()), - ); - }); - }); - - 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()); - }); - - 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()); - expect(error, isA()); - expect(error, isA()); - }); - - 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()), - ); - }); - - 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()), - ); - }); - }); } diff --git a/crdt_sync_dart/test/github_client_writes_test.dart b/crdt_sync_dart/test/github_client_writes_test.dart new file mode 100644 index 0000000..20ccd49 --- /dev/null +++ b/crdt_sync_dart/test/github_client_writes_test.dart @@ -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 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()), + ); + }, + ); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + + 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()); + }); + + 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()); + expect(error, isA()); + expect(error, isA()); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); +} diff --git a/crdt_sync_dart/test/mirror_store_fallback_test.dart b/crdt_sync_dart/test/mirror_store_fallback_test.dart new file mode 100644 index 0000000..4293edf --- /dev/null +++ b/crdt_sync_dart/test/mirror_store_fallback_test.dart @@ -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? files, this.failing = false}) + : files = files ?? {}; + + final Map files; + bool failing; + final List writes = []; + bool closed = false; + + void _guard(String what) { + if (failing) throw RemoteSyncError('$what failed'); + } + + @override + Future> 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 getFileText(String path) async { + _guard('read'); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + _guard('write'); + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async { + _guard('delete'); + files.remove(path); + } + + @override + Future 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? files]) + : _inner = _FakeStore(files: files); + + final _FakeStore _inner; + + @override + Future> listDirectory(String path) => _inner.listDirectory(path); + @override + Future getFileText(String path) => _inner.getFileText(path); + @override + Future putFileText(String p, String t, {required String message}) => + _inner.putFileText(p, t, message: message); + @override + Future deleteFile(String path, {String message = ''}) => + _inner.deleteFile(path, message: message); + @override + Future canAccessRemote() => _inner.canAccessRemote(); + @override + void close() => _inner.close(); +} + +({ + MirrorStore store, + _FakeStore primary, + _FakeStore mirror, + List failures, +}) +_mirror({ + Map? primaryFiles, + Map? mirrorFiles, + bool primaryFailing = false, + bool mirrorFailing = false, +}) { + final primary = _FakeStore(files: primaryFiles, failing: primaryFailing); + final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing); + final failures = []; + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + await expectLater( + () => m.store.deleteFile('ns/pc/log.json'), + throwsA(isA()), + ); + }); + }); +} diff --git a/crdt_sync_dart/test/mirror_store_reads_test.dart b/crdt_sync_dart/test/mirror_store_reads_test.dart new file mode 100644 index 0000000..41d7806 --- /dev/null +++ b/crdt_sync_dart/test/mirror_store_reads_test.dart @@ -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? files, this.failing = false}) + : files = files ?? {}; + + final Map files; + bool failing; + final List writes = []; + bool closed = false; + + void _guard(String what) { + if (failing) throw RemoteSyncError('$what failed'); + } + + @override + Future> 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 getFileText(String path) async { + _guard('read'); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + _guard('write'); + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async { + _guard('delete'); + files.remove(path); + } + + @override + Future canAccessRemote() async => !failing; + + @override + void close() => closed = true; +} + +({ + MirrorStore store, + _FakeStore primary, + _FakeStore mirror, + List failures, +}) +_mirror({ + Map? primaryFiles, + Map? mirrorFiles, + bool primaryFailing = false, + bool mirrorFailing = false, +}) { + final primary = _FakeStore(files: primaryFiles, failing: primaryFailing); + final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing); + final failures = []; + 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()), + ); + }); + }); +} diff --git a/crdt_sync_dart/test/mirror_store_revisions_test.dart b/crdt_sync_dart/test/mirror_store_revisions_test.dart new file mode 100644 index 0000000..0efdc07 --- /dev/null +++ b/crdt_sync_dart/test/mirror_store_revisions_test.dart @@ -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? files, this.failing = false}) + : files = files ?? {}; + + final Map files; + bool failing; + final List writes = []; + bool closed = false; + + void _guard(String what) { + if (failing) throw RemoteSyncError('$what failed'); + } + + @override + Future> 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 getFileText(String path) async { + _guard('read'); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + _guard('write'); + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async { + _guard('delete'); + files.remove(path); + } + + @override + Future 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? files]) + : _inner = _FakeStore(files: files); + + final _FakeStore _inner; + + @override + Future> listDirectory(String path) => _inner.listDirectory(path); + @override + Future getFileText(String path) => _inner.getFileText(path); + @override + Future putFileText(String p, String t, {required String message}) => + _inner.putFileText(p, t, message: message); + @override + Future deleteFile(String path, {String message = ''}) => + _inner.deleteFile(path, message: message); + @override + Future canAccessRemote() => _inner.canAccessRemote(); + @override + void close() => _inner.close(); +} + +({ + MirrorStore store, + _FakeStore primary, + _FakeStore mirror, + List failures, +}) +_mirror({ + Map? primaryFiles, + Map? mirrorFiles, + bool primaryFailing = false, + bool mirrorFailing = false, +}) { + final primary = _FakeStore(files: primaryFiles, failing: primaryFailing); + final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing); + final failures = []; + 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()); + }); + }); + + 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); + }); + }); +} diff --git a/crdt_sync_dart/test/mirror_store_test.dart b/crdt_sync_dart/test/mirror_store_test.dart index 658b2fc..5706659 100644 --- a/crdt_sync_dart/test/mirror_store_test.dart +++ b/crdt_sync_dart/test/mirror_store_test.dart @@ -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? files]) - : _inner = _FakeStore(files: files); - - final _FakeStore _inner; - - @override - Future> listDirectory(String path) => _inner.listDirectory(path); - @override - Future getFileText(String path) => _inner.getFileText(path); - @override - Future putFileText(String p, String t, {required String message}) => - _inner.putFileText(p, t, message: message); - @override - Future deleteFile(String path, {String message = ''}) => - _inner.deleteFile(path, message: message); - @override - Future 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()), - ); - }); - }); - - 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()), - ); - }); - - 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()), - ); - }); - - 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()), - ); - }); - - 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()), - ); - await expectLater( - () => m.store.deleteFile('ns/pc/log.json'), - throwsA(isA()), - ); - }); - }); - - 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()); - }); - }); - - 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); - }); - }); } diff --git a/crdt_sync_dart/test/sync_revisions_backends_test.dart b/crdt_sync_dart/test/sync_revisions_backends_test.dart new file mode 100644 index 0000000..1d10f8f --- /dev/null +++ b/crdt_sync_dart/test/sync_revisions_backends_test.dart @@ -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).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +/// 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 files; + final List reads = []; + final List writes = []; + final List mapReads = []; + + @override + Future> listDirectory(String path) async => files.keys + .where((key) => key.startsWith('$path/')) + .map((key) => key.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + + @override + Future getFileText(String path) async { + reads.add(path); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async => + files.remove(path); + + @override + Future 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 files) + : _inner = _FakeRemote(files); + + final _FakeRemote _inner; + + Map get files => _inner.files; + List get reads => _inner.reads; + List get writes => _inner.writes; + + @override + Future> listDirectory(String path) => _inner.listDirectory(path); + + @override + Future getFileText(String path) => _inner.getFileText(path); + + @override + Future putFileText( + String path, + String text, { + required String message, + }) => _inner.putFileText(path, text, message: message); + + @override + Future deleteFile(String path, {String message = ''}) => + _inner.deleteFile(path, message: message); + + @override + Future canAccessRemote() => _inner.canAccessRemote(); + + @override + void close() => _inner.close(); +} + +Future _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')); + }); + }); +} diff --git a/crdt_sync_dart/test/sync_revisions_order_test.dart b/crdt_sync_dart/test/sync_revisions_order_test.dart new file mode 100644 index 0000000..ade4f3f --- /dev/null +++ b/crdt_sync_dart/test/sync_revisions_order_test.dart @@ -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).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +/// 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 files; + final List reads = []; + final List writes = []; + final List mapReads = []; + + @override + Future> listDirectory(String path) async => files.keys + .where((key) => key.startsWith('$path/')) + .map((key) => key.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + + @override + Future getFileText(String path) async { + reads.add(path); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async => + files.remove(path); + + @override + Future canAccessRemote() async => true; + + @override + void close() {} +} + +Future _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', () {}); +} diff --git a/crdt_sync_dart/test/sync_revisions_peers_test.dart b/crdt_sync_dart/test/sync_revisions_peers_test.dart new file mode 100644 index 0000000..f8b87d8 --- /dev/null +++ b/crdt_sync_dart/test/sync_revisions_peers_test.dart @@ -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).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +/// 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 files; + final List reads = []; + final List writes = []; + final List mapReads = []; + + @override + Future> listDirectory(String path) async => files.keys + .where((key) => key.startsWith('$path/')) + .map((key) => key.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + + @override + Future getFileText(String path) async { + reads.add(path); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async => + files.remove(path); + + @override + Future canAccessRemote() async => true; + + @override + void close() {} +} + +Future _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); + }); + }); +} diff --git a/crdt_sync_dart/test/sync_revisions_persistence_test.dart b/crdt_sync_dart/test/sync_revisions_persistence_test.dart new file mode 100644 index 0000000..1ab2605 --- /dev/null +++ b/crdt_sync_dart/test/sync_revisions_persistence_test.dart @@ -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).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +/// 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 files; + final List reads = []; + final List writes = []; + final List mapReads = []; + + @override + Future> listDirectory(String path) async => files.keys + .where((key) => key.startsWith('$path/')) + .map((key) => key.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + + @override + Future getFileText(String path) async { + reads.add(path); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes.add(path); + files[path] = text; + } + + @override + Future> 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 deleteFile(String path, {String message = ''}) async => + files.remove(path); + + @override + Future canAccessRemote() async => true; + + @override + void close() {} +} + +Future _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 read() async { + if (readThrows) throw const FormatException('unreadable'); + return text; + } + + @override + Future write(String value) async => text = value; +} diff --git a/crdt_sync_dart/test/sync_revisions_test.dart b/crdt_sync_dart/test/sync_revisions_test.dart index 2e818b8..de0643d 100644 --- a/crdt_sync_dart/test/sync_revisions_test.dart +++ b/crdt_sync_dart/test/sync_revisions_test.dart @@ -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 files) - : _inner = _FakeRemote(files); - - final _FakeRemote _inner; - - Map get files => _inner.files; - List get reads => _inner.reads; - List get writes => _inner.writes; - - @override - Future> listDirectory(String path) => _inner.listDirectory(path); - - @override - Future getFileText(String path) => _inner.getFileText(path); - - @override - Future putFileText( - String path, - String text, { - required String message, - }) => _inner.putFileText(path, text, message: message); - - @override - Future deleteFile(String path, {String message = ''}) => - _inner.deleteFile(path, message: message); - - @override - Future canAccessRemote() => _inner.canAccessRemote(); - - @override - void close() => _inner.close(); -} - Future _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 read() async { - if (readThrows) throw const FormatException('unreadable'); - return text; - } - - @override - Future write(String value) async => text = value; } diff --git a/crdt_sync_dart/test/sync_skips_test.dart b/crdt_sync_dart/test/sync_skips_test.dart new file mode 100644 index 0000000..dee94f4 --- /dev/null +++ b/crdt_sync_dart/test/sync_skips_test.dart @@ -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).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +http.Response _response(int statusCode, [Object? jsonBody]) => + http.Response(jsonEncode(jsonBody ?? {}), statusCode); + +class _PutCall { + _PutCall(this.path, this.body); + final String path; + final Map body; +} + +/// Builds a [GitHubClient] backed by an in-memory router: GET +/// `.../contents/` 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 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), + ); + 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 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 = { + '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'); + }); + }); +} diff --git a/crdt_sync_dart/test/sync_test.dart b/crdt_sync_dart/test/sync_test.dart index ec9dcf2..c080e09 100644 --- a/crdt_sync_dart/test/sync_test.dart +++ b/crdt_sync_dart/test/sync_test.dart @@ -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 = { - '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'); - }); }); } diff --git a/github_device_auth/test/device_auth_poll_test.dart b/github_device_auth/test/device_auth_poll_test.dart new file mode 100644 index 0000000..964686f --- /dev/null +++ b/github_device_auth/test/device_auth_poll_test.dart @@ -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 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() + .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().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().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().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'); + }); +} diff --git a/github_device_auth/test/device_auth_test.dart b/github_device_auth/test/device_auth_test.dart index c4ea6cc..be6c528 100644 --- a/github_device_auth/test/device_auth_test.dart +++ b/github_device_auth/test/device_auth_test.dart @@ -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() - .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().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().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() - .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'); - }); } diff --git a/sync_settings_ui/test/sync_settings_screen_backup_test.dart b/sync_settings_ui/test/sync_settings_screen_backup_test.dart new file mode 100644 index 0000000..dfb3713 --- /dev/null +++ b/sync_settings_ui/test/sync_settings_screen_backup_test.dart @@ -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(); + 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); + }); +} diff --git a/sync_settings_ui/test/sync_settings_screen_google_test.dart b/sync_settings_ui/test/sync_settings_screen_google_test.dart new file mode 100644 index 0000000..2ec8899 --- /dev/null +++ b/sync_settings_ui/test/sync_settings_screen_google_test.dart @@ -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); + }, + ); + }); +} diff --git a/sync_settings_ui/test/sync_settings_screen_test.dart b/sync_settings_ui/test/sync_settings_screen_test.dart index 7bfa7e2..880ef42 100644 --- a/sync_settings_ui/test/sync_settings_screen_test.dart +++ b/sync_settings_ui/test/sync_settings_screen_test.dart @@ -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(); - 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); }); }