dart: split ten oversized test files by group

firebase_auth_rest_test.dart  525 -> 153 + _google 158 + _tokens 206 + _uid 141
  sync_revisions_test.dart      451 -> 223 + _peers 228 + _backends 158
                                       + _order 165 + _persistence 215
  sync_settings_screen_test     457 -> 207 + _backup 143 + _google 145
  mirror_store_test.dart        452 -> 209 + _reads 172 + _fallback 196 + _revisions 244
  firebase_client_test.dart     374 -> 198 + _writes 227
  firebase_config_test.dart     360 -> 138 + _client 148 + _client_google 164
  device_auth_test.dart         307 -> 128 + _poll 201
  github_client_test.dart       291 -> 143 + _writes 201
  sync_test.dart                269 -> 160 + _skips 175

Two traps this hit, both invisible to a line count:

  - _FakePersistence is declared *after* main(), so a naive cut at the last
    brace dropped it; sync_revisions_persistence_test.dart failed to load
    until it was carried across. Audited every other split file for trailing
    top-level declarations — this was the only one.
  - Copying the preamble into each sibling left helpers the sibling never
    uses. `dart analyze` flagged 12; all removed rather than left as dead
    code, and the analyzer is now clean.

crdt_sync_dart 280 passed, sync_settings_ui 29 passed, github_device_auth
analyze clean — all matching a clean HEAD worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 14:54:35 +02:00
parent 9d897c13f4
commit 200432a2b4
27 changed files with 3159 additions and 2020 deletions

View File

@ -0,0 +1,152 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
/// A fixed "now" so token expiry is deterministic rather than wall-clock.
final _now = DateTime.utc(2026, 8, 3, 12);
http.Response _json(int statusCode, Object body) =>
http.Response(jsonEncode(body), statusCode);
/// Builds a provider whose POSTs return [responses] in call order (each
/// either an [http.Response] or a thrown [http.ClientException]).
FirebaseTokenProvider _provider(
List<Object> responses, {
FirebaseCredentialStore? store,
DateTime? now,
}) {
var index = 0;
final mock = http_testing.MockClient((request) async {
final response = responses[index];
index++;
if (response is http.ClientException) throw response;
return response as http.Response;
});
return FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: store ?? InMemoryCredentialStore(),
httpClient: mock,
clock: () => now ?? _now,
);
}
void main() {
group('signInWithGoogle', () {
test('stores the session returned by identitytoolkit', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'google-token');
final saved = await store.load();
expect(saved!.idToken, 'id-g');
expect(saved.refreshToken, 'refresh-g');
expect(saved.expiresAt, _now.add(const Duration(hours: 1)));
});
test('posts the IdP credential form-encoded to signInWithIdp', () async {
// The identitytoolkit quirk worth pinning: the Google token travels in a
// form-encoded `postBody` string, not as a JSON field. Getting this
// wrong returns INVALID_IDP_RESPONSE, which reads like a bad token.
late http.Request captured;
final provider = FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(),
httpClient: http_testing.MockClient((request) async {
captured = request;
return _json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
});
}),
clock: () => _now,
);
await provider.signInWithGoogle(idToken: 'google-token');
expect(captured.url.path, contains('accounts:signInWithIdp'));
final body = jsonDecode(captured.body) as Map<String, dynamic>;
expect(body['postBody'], 'id_token=google-token&providerId=google.com');
expect(body['returnSecureToken'], isTrue);
});
test('reports Google\'s reason on a rejected token', () async {
final provider = _provider([
_json(400, {
'error': {'message': 'INVALID_IDP_RESPONSE'},
}),
]);
expect(
() => provider.signInWithGoogle(idToken: 'stale-token'),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('INVALID_IDP_RESPONSE'),
),
),
);
});
test('leaves no session stored when the token is rejected', () async {
// A failed Google sign-in must not look like a signed-in device: the
// next idToken() has to fail loudly rather than serve a stale token.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(400, {
'error': {'message': 'INVALID_IDP_RESPONSE'},
}),
], store: store);
await expectLater(
() => provider.signInWithGoogle(idToken: 'bad'),
throwsA(isA<FirebaseAuthError>()),
);
expect(await store.load(), isNull);
expect(await provider.hasSession(), isFalse);
});
test('returns the email Firebase reports', () async {
// A fresh install knows no email until Firebase answers with one, so
// this return value is what lets the device store its account. Reading
// the email from anywhere on-device instead would store an empty one.
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'email': 'signed-in@example.com',
}),
]);
expect(
await provider.signInWithGoogle(idToken: 'token'),
'signed-in@example.com',
);
});
test('returns null when the response carries no email', () async {
// Not fatal: the session is still valid, so sign-in succeeds and the
// caller simply has no address to display.
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
}),
]);
expect(await provider.signInWithGoogle(idToken: 'token'), isNull);
});
});
}

View File

@ -150,376 +150,4 @@ void main() {
); );
}); });
}); });
group('signInWithGoogle', () {
test('stores the session returned by identitytoolkit', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'google-token');
final saved = await store.load();
expect(saved!.idToken, 'id-g');
expect(saved.refreshToken, 'refresh-g');
expect(saved.expiresAt, _now.add(const Duration(hours: 1)));
});
test('posts the IdP credential form-encoded to signInWithIdp', () async {
// The identitytoolkit quirk worth pinning: the Google token travels in a
// form-encoded `postBody` string, not as a JSON field. Getting this
// wrong returns INVALID_IDP_RESPONSE, which reads like a bad token.
late http.Request captured;
final provider = FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(),
httpClient: http_testing.MockClient((request) async {
captured = request;
return _json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
});
}),
clock: () => _now,
);
await provider.signInWithGoogle(idToken: 'google-token');
expect(captured.url.path, contains('accounts:signInWithIdp'));
final body = jsonDecode(captured.body) as Map<String, dynamic>;
expect(body['postBody'], 'id_token=google-token&providerId=google.com');
expect(body['returnSecureToken'], isTrue);
});
test('reports Google\'s reason on a rejected token', () async {
final provider = _provider([
_json(400, {
'error': {'message': 'INVALID_IDP_RESPONSE'},
}),
]);
expect(
() => provider.signInWithGoogle(idToken: 'stale-token'),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('INVALID_IDP_RESPONSE'),
),
),
);
});
test('leaves no session stored when the token is rejected', () async {
// A failed Google sign-in must not look like a signed-in device: the
// next idToken() has to fail loudly rather than serve a stale token.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(400, {
'error': {'message': 'INVALID_IDP_RESPONSE'},
}),
], store: store);
await expectLater(
() => provider.signInWithGoogle(idToken: 'bad'),
throwsA(isA<FirebaseAuthError>()),
);
expect(await store.load(), isNull);
expect(await provider.hasSession(), isFalse);
});
test('returns the email Firebase reports', () async {
// A fresh install knows no email until Firebase answers with one, so
// this return value is what lets the device store its account. Reading
// the email from anywhere on-device instead would store an empty one.
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'email': 'signed-in@example.com',
}),
]);
expect(
await provider.signInWithGoogle(idToken: 'token'),
'signed-in@example.com',
);
});
test('returns null when the response carries no email', () async {
// Not fatal: the session is still valid, so sign-in succeeds and the
// caller simply has no address to display.
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
}),
]);
expect(await provider.signInWithGoogle(idToken: 'token'), isNull);
});
test('rejects a session for a uid other than the expected one', () async {
// The mis-tapped-account-picker case. signInWithIdp signs in OR SIGNS
// UP, so an unlinked Google identity is accepted as a brand-new user
// rather than refused. Without this check the device would store that
// session and then be denied every read and write, permanently.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'some-other-uid',
}),
], store: store);
await expectLater(
() => provider.signInWithGoogle(
idToken: 'token-for-the-wrong-account',
expectedUid: 'the-right-uid',
),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
allOf(contains('some-other-uid'), contains('the-right-uid')),
),
),
);
expect(
await store.load(),
isNull,
reason: 'a wrong-account session must not persist',
);
expect(await provider.hasSession(), isFalse);
});
test('accepts a session whose uid matches the expected one', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'the-right-uid',
}),
], store: store);
await provider.signInWithGoogle(
idToken: 'token',
expectedUid: 'the-right-uid',
);
expect((await store.load())!.idToken, 'id-g');
});
test('skips the uid check when no uid is expected', () async {
// FirebaseProject.configFor leaves uid empty on the app path; comparing
// against '' would otherwise fail every sign-in.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'whatever-uid',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'token', expectedUid: '');
expect((await store.load())!.idToken, 'id-g');
});
test('its session refreshes like a password session', () async {
// signInWithIdp returns the same refresh-token shape as
// signInWithPassword, which is why the refresh path needed no changes.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
// Already inside the refresh skew, so the next read must refresh.
'expiresIn': '60',
}),
_json(200, {
'id_token': 'id-g2',
'refresh_token': 'refresh-g2',
'expires_in': '3600',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'google-token');
expect(await provider.idToken(), 'id-g2');
expect((await store.load())!.refreshToken, 'refresh-g2');
});
});
group('idToken', () {
test('fails loudly when no session is stored', () {
// Never returns null or a stale token: a sync that quietly stops
// syncing is exactly the failure mode being designed out.
expect(
_provider([]).idToken,
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('not signed in'),
),
),
);
});
test('returns the stored token without a network call', () async {
// An empty response list means any HTTP call would throw a range error.
final provider = _provider(
[],
store: InMemoryCredentialStore(_credentials()),
);
expect(await provider.idToken(), 'id-1');
});
test(
'refreshes an expired token and persists the rotated refresh token',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
);
final provider = _provider([
_json(200, {
'id_token': 'id-2',
'refresh_token': 'refresh-2',
'expires_in': '3600',
}),
], store: store);
expect(await provider.idToken(), 'id-2');
final saved = await store.load();
expect(saved!.refreshToken, 'refresh-2');
},
);
test('fails loudly when the refresh token has been revoked', () async {
final provider = _provider(
[
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
],
store: InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
),
);
expect(
provider.idToken,
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('TOKEN_EXPIRED'),
),
),
);
});
});
group('session lifecycle', () {
test('hasSession is false before sign-in and true after', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-1',
'refreshToken': 'refresh-1',
'expiresIn': '3600',
}),
], store: store);
expect(await provider.hasSession(), isFalse);
await provider.signIn(email: 'a@b.c', password: 'x');
expect(await provider.hasSession(), isTrue);
});
test('signOut clears the store so the next idToken fails', () async {
final provider = _provider(
[],
store: InMemoryCredentialStore(_credentials()),
);
expect(await provider.idToken(), 'id-1');
await provider.signOut();
expect(provider.idToken, throwsA(isA<FirebaseAuthError>()));
});
test('close releases the HTTP client', () {
expect(_provider([]).close, returnsNormally);
});
});
group('a refresh token the server rejects', () {
// Regression: hasSession() was a presence check, so a revoked token still
// read as "Connected" while every sync died with TOKEN_EXPIRED. Observed
// on the desktop todo build on 2026-08-11.
test(
'is cleared, so hasSession stops claiming the device is connected',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(
await provider.hasSession(),
isFalse,
reason: 'a revoked session must not keep reporting as connected',
);
expect(await store.load(), isNull);
},
);
test('survives a network error, which is transient', () async {
// The opposite failure: signing out whenever the wifi drops would need a
// manual sign-in on every device to recover.
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
http.ClientException('connection reset'),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(
await provider.hasSession(),
isTrue,
reason: 'a transient failure must not discard a good refresh token',
);
expect(await store.load(), isNotNull);
});
test('survives a 5xx, which is also transient', () async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
_json(503, {
'error': {'message': 'SERVICE_UNAVAILABLE'},
}),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(await provider.hasSession(), isTrue);
});
});
} }

View File

@ -0,0 +1,206 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
/// A fixed "now" so token expiry is deterministic rather than wall-clock.
final _now = DateTime.utc(2026, 8, 3, 12);
http.Response _json(int statusCode, Object body) =>
http.Response(jsonEncode(body), statusCode);
/// Builds a provider whose POSTs return [responses] in call order (each
/// either an [http.Response] or a thrown [http.ClientException]).
FirebaseTokenProvider _provider(
List<Object> responses, {
FirebaseCredentialStore? store,
DateTime? now,
}) {
var index = 0;
final mock = http_testing.MockClient((request) async {
final response = responses[index];
index++;
if (response is http.ClientException) throw response;
return response as http.Response;
});
return FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: store ?? InMemoryCredentialStore(),
httpClient: mock,
clock: () => now ?? _now,
);
}
FirebaseCredentials _credentials({
String idToken = 'id-1',
String refreshToken = 'refresh-1',
Duration validFor = const Duration(hours: 1),
}) => FirebaseCredentials(
idToken: idToken,
refreshToken: refreshToken,
expiresAt: _now.add(validFor),
);
void main() {
group('idToken', () {
test('fails loudly when no session is stored', () {
// Never returns null or a stale token: a sync that quietly stops
// syncing is exactly the failure mode being designed out.
expect(
_provider([]).idToken,
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('not signed in'),
),
),
);
});
test('returns the stored token without a network call', () async {
// An empty response list means any HTTP call would throw a range error.
final provider = _provider(
[],
store: InMemoryCredentialStore(_credentials()),
);
expect(await provider.idToken(), 'id-1');
});
test(
'refreshes an expired token and persists the rotated refresh token',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
);
final provider = _provider([
_json(200, {
'id_token': 'id-2',
'refresh_token': 'refresh-2',
'expires_in': '3600',
}),
], store: store);
expect(await provider.idToken(), 'id-2');
final saved = await store.load();
expect(saved!.refreshToken, 'refresh-2');
},
);
test('fails loudly when the refresh token has been revoked', () async {
final provider = _provider(
[
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
],
store: InMemoryCredentialStore(
_credentials(validFor: const Duration(minutes: -5)),
),
);
expect(
provider.idToken,
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
contains('TOKEN_EXPIRED'),
),
),
);
});
});
group('session lifecycle', () {
test('hasSession is false before sign-in and true after', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-1',
'refreshToken': 'refresh-1',
'expiresIn': '3600',
}),
], store: store);
expect(await provider.hasSession(), isFalse);
await provider.signIn(email: 'a@b.c', password: 'x');
expect(await provider.hasSession(), isTrue);
});
test('signOut clears the store so the next idToken fails', () async {
final provider = _provider(
[],
store: InMemoryCredentialStore(_credentials()),
);
expect(await provider.idToken(), 'id-1');
await provider.signOut();
expect(provider.idToken, throwsA(isA<FirebaseAuthError>()));
});
test('close releases the HTTP client', () {
expect(_provider([]).close, returnsNormally);
});
});
group('a refresh token the server rejects', () {
// Regression: hasSession() was a presence check, so a revoked token still
// read as "Connected" while every sync died with TOKEN_EXPIRED. Observed
// on the desktop todo build on 2026-08-11.
test(
'is cleared, so hasSession stops claiming the device is connected',
() async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
_json(400, {
'error': {'message': 'TOKEN_EXPIRED'},
}),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(
await provider.hasSession(),
isFalse,
reason: 'a revoked session must not keep reporting as connected',
);
expect(await store.load(), isNull);
},
);
test('survives a network error, which is transient', () async {
// The opposite failure: signing out whenever the wifi drops would need a
// manual sign-in on every device to recover.
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
http.ClientException('connection reset'),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(
await provider.hasSession(),
isTrue,
reason: 'a transient failure must not discard a good refresh token',
);
expect(await store.load(), isNotNull);
});
test('survives a 5xx, which is also transient', () async {
final store = InMemoryCredentialStore(
_credentials(validFor: const Duration(seconds: -1)),
);
final provider = _provider([
_json(503, {
'error': {'message': 'SERVICE_UNAVAILABLE'},
}),
], store: store);
await expectLater(provider.idToken, throwsA(isA<FirebaseAuthError>()));
expect(await provider.hasSession(), isTrue);
});
});
}

View File

@ -0,0 +1,134 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
/// A fixed "now" so token expiry is deterministic rather than wall-clock.
final _now = DateTime.utc(2026, 8, 3, 12);
http.Response _json(int statusCode, Object body) =>
http.Response(jsonEncode(body), statusCode);
/// Builds a provider whose POSTs return [responses] in call order (each
/// either an [http.Response] or a thrown [http.ClientException]).
FirebaseTokenProvider _provider(
List<Object> responses, {
FirebaseCredentialStore? store,
DateTime? now,
}) {
var index = 0;
final mock = http_testing.MockClient((request) async {
final response = responses[index];
index++;
if (response is http.ClientException) throw response;
return response as http.Response;
});
return FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: store ?? InMemoryCredentialStore(),
httpClient: mock,
clock: () => now ?? _now,
);
}
void main() {
group('signInWithGoogle uid verification', () {
test('rejects a session for a uid other than the expected one', () async {
// The mis-tapped-account-picker case. signInWithIdp signs in OR SIGNS
// UP, so an unlinked Google identity is accepted as a brand-new user
// rather than refused. Without this check the device would store that
// session and then be denied every read and write, permanently.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'some-other-uid',
}),
], store: store);
await expectLater(
() => provider.signInWithGoogle(
idToken: 'token-for-the-wrong-account',
expectedUid: 'the-right-uid',
),
throwsA(
isA<FirebaseAuthError>().having(
(e) => e.message,
'message',
allOf(contains('some-other-uid'), contains('the-right-uid')),
),
),
);
expect(
await store.load(),
isNull,
reason: 'a wrong-account session must not persist',
);
expect(await provider.hasSession(), isFalse);
});
test('accepts a session whose uid matches the expected one', () async {
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'the-right-uid',
}),
], store: store);
await provider.signInWithGoogle(
idToken: 'token',
expectedUid: 'the-right-uid',
);
expect((await store.load())!.idToken, 'id-g');
});
test('skips the uid check when no uid is expected', () async {
// FirebaseProject.configFor leaves uid empty on the app path; comparing
// against '' would otherwise fail every sign-in.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
'expiresIn': '3600',
'localId': 'whatever-uid',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'token', expectedUid: '');
expect((await store.load())!.idToken, 'id-g');
});
test('its session refreshes like a password session', () async {
// signInWithIdp returns the same refresh-token shape as
// signInWithPassword, which is why the refresh path needed no changes.
final store = InMemoryCredentialStore();
final provider = _provider([
_json(200, {
'idToken': 'id-g',
'refreshToken': 'refresh-g',
// Already inside the refresh skew, so the next read must refresh.
'expiresIn': '60',
}),
_json(200, {
'id_token': 'id-g2',
'refresh_token': 'refresh-g2',
'expires_in': '3600',
}),
], store: store);
await provider.signInWithGoogle(idToken: 'google-token');
expect(await provider.idToken(), 'id-g2');
expect((await store.load())!.refreshToken, 'refresh-g2');
});
});
}

View File

@ -5,7 +5,8 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing; import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart'; import 'package:test/test.dart';
const _databaseUrl = 'https://example-default-rtdb.europe-west1.firebasedatabase.app'; const _databaseUrl =
'https://example-default-rtdb.europe-west1.firebasedatabase.app';
http.Response _json(int statusCode, Object? body) => http.Response _json(int statusCode, Object? body) =>
http.Response(jsonEncode(body), statusCode); http.Response(jsonEncode(body), statusCode);
@ -31,10 +32,7 @@ FirebaseTokenProvider _auth() => FirebaseTokenProvider(
/// decoded result. /// decoded result.
late List<http.Request> requests; late List<http.Request> requests;
FirebaseRestClient _client( FirebaseRestClient _client(Object response, {FirebaseTokenProvider? auth}) {
Object response, {
FirebaseTokenProvider? auth,
}) {
requests = []; requests = [];
final mock = http_testing.MockClient((request) async { final mock = http_testing.MockClient((request) async {
requests.add(request); requests.add(request);
@ -58,8 +56,10 @@ void main() {
test('leaves dot-free device ids untouched', () { test('leaves dot-free device ids untouched', () {
expect(encodeKey('pc'), 'pc'); expect(encodeKey('pc'), 'pc');
expect(encodeKey('32c28cf3-c0eb-423f-8bac-9bda9f158054'), expect(
'32c28cf3-c0eb-423f-8bac-9bda9f158054'); encodeKey('32c28cf3-c0eb-423f-8bac-9bda9f158054'),
'32c28cf3-c0eb-423f-8bac-9bda9f158054',
);
}); });
test('escapes every character RTDB forbids, plus the escape char', () { test('escapes every character RTDB forbids, plus the escape char', () {
@ -103,10 +103,7 @@ void main() {
test('requests the escaped path with the auth token', () async { test('requests the escaped path with the auth token', () async {
final client = _client(_json(200, '{}')); final client = _client(_json(200, '{}'));
await client.getFileText('ns/devices/pc/log.json'); await client.getFileText('ns/devices/pc/log.json');
expect( expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
requests.single.url.path,
'/ns/devices/pc/log~2Ejson.json',
);
expect(requests.single.url.queryParameters['auth'], 'id-token'); expect(requests.single.url.queryParameters['auth'], 'id-token');
}); });
@ -125,13 +122,16 @@ void main() {
); );
}); });
test('raises DatabaseNotFoundError when the rules reject the uid', () async { test(
final client = _client(_json(401, {'error': 'Permission denied'})); 'raises DatabaseNotFoundError when the rules reject the uid',
expect( () async {
() => client.getFileText('ns/devices/pc/log.json'), final client = _client(_json(401, {'error': 'Permission denied'}));
throwsA(isA<DatabaseNotFoundError>()), expect(
); () => client.getFileText('ns/devices/pc/log.json'),
}); throwsA(isA<DatabaseNotFoundError>()),
);
},
);
test('raises DatabaseNotFoundError on 403 too', () async { test('raises DatabaseNotFoundError on 403 too', () async {
final client = _client(_json(403, {'error': 'Permission denied'})); final client = _client(_json(403, {'error': 'Permission denied'}));
@ -195,180 +195,4 @@ void main() {
); );
}); });
}); });
group('putFileText', () {
test('writes the text as a JSON string leaf', () async {
final client = _client(_json(200, '{"a":1}'));
await client.putFileText(
'ns/devices/pc/log.json',
'{"a":1}',
message: 'ignored by rtdb',
);
expect(requests.single.method, 'PUT');
expect(requests.single.body, jsonEncode('{"a":1}'));
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
});
test('raises on a non-2xx response', () async {
final client = _client(_json(400, {'error': 'bad'}));
expect(
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<FirebaseSyncError>()),
);
});
test('turns a network failure into FirebaseSyncError', () async {
final client = _client(http.ClientException('offline'));
expect(
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('patchValues', () {
test('PATCHes rather than PUTs, so sibling keys survive', () async {
// A PUT on the shared revs map would wipe every other device's entry,
// after which those devices would look permanently unchanged and never
// be fetched again.
final client = _client(_json(200, {'pc': 'abc'}));
await client.patchValues('ns/revs', {'pc': 'abc'});
expect(requests.single.method, 'PATCH');
expect(requests.single.body, jsonEncode({'pc': 'abc'}));
});
test('raises on a non-2xx response', () async {
final client = _client(_json(500, {'error': 'boom'}));
expect(
() => client.patchValues('ns/revs', {'pc': 'abc'}),
throwsA(isA<FirebaseSyncError>()),
);
});
test('turns a network failure into FirebaseSyncError', () async {
final client = _client(http.ClientException('offline'));
expect(
() => client.patchValues('ns/revs', {'pc': 'abc'}),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('getStringMap', () {
test('returns the map with keys decoded', () async {
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 'sha-2'}));
expect(await client.getStringMap('ns/revs'), {
'pc': 'sha-1',
'phone': 'sha-2',
});
});
test('degrades to empty when absent', () async {
final client = _client(_json(200, null));
expect(await client.getStringMap('ns/revs'), isEmpty);
});
test('skips non-string entries rather than failing the sync', () async {
// The revs node is an optimisation; a corrupt one must degrade into
// "fetch everything", never into a failed tick.
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 42}));
expect(await client.getStringMap('ns/revs'), {'pc': 'sha-1'});
});
test('raises on a non-2xx response', () async {
final client = _client(_json(500, {'error': 'boom'}));
expect(
() => client.getStringMap('ns/revs'),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('deleteFile', () {
test('sends a DELETE to the escaped path', () async {
final client = _client(_json(200, null));
await client.deleteFile('ns/devices/pc/log.json');
expect(requests.single.method, 'DELETE');
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
});
test('raises on a non-2xx response', () async {
final client = _client(_json(500, {'error': 'boom'}));
expect(
() => client.deleteFile('ns/devices/pc/log.json'),
throwsA(isA<FirebaseSyncError>()),
);
});
test('turns a network failure into FirebaseSyncError', () async {
final client = _client(http.ClientException('offline'));
expect(
() => client.deleteFile('ns/devices/pc/log.json'),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('canAccessRemote', () {
test('is true when the database root reads', () async {
expect(await _client(_json(200, {'ns': true})).canAccessRemote(), isTrue);
});
test('is false when the rules reject the token', () async {
expect(await _client(_json(401, {'error': 'denied'})).canAccessRemote(),
isFalse);
});
test('is false on a network error', () async {
expect(
await _client(http.ClientException('offline')).canAccessRemote(),
isFalse,
);
});
test('is false when no session is stored, rather than throwing', () async {
// "cannot get a token" is exactly "cannot access the remote", and a
// settings screen's Test-connection button must not blow up.
final client = _client(
_json(200, {}),
auth: FirebaseTokenProvider(
apiKey: 'k',
store: InMemoryCredentialStore(),
httpClient: http_testing.MockClient((_) async => _json(200, {})),
),
);
expect(await client.canAccessRemote(), isFalse);
});
});
group('RemoteStore contract', () {
test('FirebaseRestClient is a RemoteStore', () {
expect(_client(_json(200, null)), isA<RemoteStore>());
});
test('DatabaseNotFoundError is catchable as either error type', () {
final error = DatabaseNotFoundError('gone');
expect(error, isA<FirebaseSyncError>());
expect(error, isA<RemoteNotFoundError>());
expect(error, isA<RemoteSyncError>());
});
test('a trailing slash on the database URL is tolerated', () async {
final client = FirebaseRestClient(
databaseUrl: '$_databaseUrl/',
auth: _auth(),
httpClient: http_testing.MockClient((request) async {
requests.add(request);
return _json(200, 'ok');
}),
);
requests = [];
await client.getFileText('ns/x.json');
expect(requests.single.url.toString(), startsWith('$_databaseUrl/ns/'));
});
test('close releases the HTTP client', () {
expect(_client(_json(200, null)).close, returnsNormally);
});
});
} }

View File

@ -0,0 +1,227 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _databaseUrl =
'https://example-default-rtdb.europe-west1.firebasedatabase.app';
http.Response _json(int statusCode, Object? body) =>
http.Response(jsonEncode(body), statusCode);
/// A provider holding a long-lived token, so client tests never hit the auth
/// endpoints -- an HTTP call from the token provider would fail loudly here.
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id-token',
refreshToken: 'refresh-token',
expiresAt: DateTime.utc(2099),
),
),
httpClient: http_testing.MockClient(
(_) async => throw StateError('auth must not be contacted'),
),
);
/// Records every request the client makes, so tests can assert on the URL
/// (which carries the key escaping and the shallow flag) as well as the
/// decoded result.
late List<http.Request> requests;
FirebaseRestClient _client(Object response, {FirebaseTokenProvider? auth}) {
requests = [];
final mock = http_testing.MockClient((request) async {
requests.add(request);
if (response is http.ClientException) throw response;
return response as http.Response;
});
return FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: auth ?? _auth(),
httpClient: mock,
);
}
void main() {
group('putFileText', () {
test('writes the text as a JSON string leaf', () async {
final client = _client(_json(200, '{"a":1}'));
await client.putFileText(
'ns/devices/pc/log.json',
'{"a":1}',
message: 'ignored by rtdb',
);
expect(requests.single.method, 'PUT');
expect(requests.single.body, jsonEncode('{"a":1}'));
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
});
test('raises on a non-2xx response', () async {
final client = _client(_json(400, {'error': 'bad'}));
expect(
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<FirebaseSyncError>()),
);
});
test('turns a network failure into FirebaseSyncError', () async {
final client = _client(http.ClientException('offline'));
expect(
() => client.putFileText('ns/devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('patchValues', () {
test('PATCHes rather than PUTs, so sibling keys survive', () async {
// A PUT on the shared revs map would wipe every other device's entry,
// after which those devices would look permanently unchanged and never
// be fetched again.
final client = _client(_json(200, {'pc': 'abc'}));
await client.patchValues('ns/revs', {'pc': 'abc'});
expect(requests.single.method, 'PATCH');
expect(requests.single.body, jsonEncode({'pc': 'abc'}));
});
test('raises on a non-2xx response', () async {
final client = _client(_json(500, {'error': 'boom'}));
expect(
() => client.patchValues('ns/revs', {'pc': 'abc'}),
throwsA(isA<FirebaseSyncError>()),
);
});
test('turns a network failure into FirebaseSyncError', () async {
final client = _client(http.ClientException('offline'));
expect(
() => client.patchValues('ns/revs', {'pc': 'abc'}),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('getStringMap', () {
test('returns the map with keys decoded', () async {
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 'sha-2'}));
expect(await client.getStringMap('ns/revs'), {
'pc': 'sha-1',
'phone': 'sha-2',
});
});
test('degrades to empty when absent', () async {
final client = _client(_json(200, null));
expect(await client.getStringMap('ns/revs'), isEmpty);
});
test('skips non-string entries rather than failing the sync', () async {
// The revs node is an optimisation; a corrupt one must degrade into
// "fetch everything", never into a failed tick.
final client = _client(_json(200, {'pc': 'sha-1', 'phone': 42}));
expect(await client.getStringMap('ns/revs'), {'pc': 'sha-1'});
});
test('raises on a non-2xx response', () async {
final client = _client(_json(500, {'error': 'boom'}));
expect(
() => client.getStringMap('ns/revs'),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('deleteFile', () {
test('sends a DELETE to the escaped path', () async {
final client = _client(_json(200, null));
await client.deleteFile('ns/devices/pc/log.json');
expect(requests.single.method, 'DELETE');
expect(requests.single.url.path, '/ns/devices/pc/log~2Ejson.json');
});
test('raises on a non-2xx response', () async {
final client = _client(_json(500, {'error': 'boom'}));
expect(
() => client.deleteFile('ns/devices/pc/log.json'),
throwsA(isA<FirebaseSyncError>()),
);
});
test('turns a network failure into FirebaseSyncError', () async {
final client = _client(http.ClientException('offline'));
expect(
() => client.deleteFile('ns/devices/pc/log.json'),
throwsA(isA<FirebaseSyncError>()),
);
});
});
group('canAccessRemote', () {
test('is true when the database root reads', () async {
expect(await _client(_json(200, {'ns': true})).canAccessRemote(), isTrue);
});
test('is false when the rules reject the token', () async {
expect(
await _client(_json(401, {'error': 'denied'})).canAccessRemote(),
isFalse,
);
});
test('is false on a network error', () async {
expect(
await _client(http.ClientException('offline')).canAccessRemote(),
isFalse,
);
});
test('is false when no session is stored, rather than throwing', () async {
// "cannot get a token" is exactly "cannot access the remote", and a
// settings screen's Test-connection button must not blow up.
final client = _client(
_json(200, {}),
auth: FirebaseTokenProvider(
apiKey: 'k',
store: InMemoryCredentialStore(),
httpClient: http_testing.MockClient((_) async => _json(200, {})),
),
);
expect(await client.canAccessRemote(), isFalse);
});
});
group('RemoteStore contract', () {
test('FirebaseRestClient is a RemoteStore', () {
expect(_client(_json(200, null)), isA<RemoteStore>());
});
test('DatabaseNotFoundError is catchable as either error type', () {
final error = DatabaseNotFoundError('gone');
expect(error, isA<FirebaseSyncError>());
expect(error, isA<RemoteNotFoundError>());
expect(error, isA<RemoteSyncError>());
});
test('a trailing slash on the database URL is tolerated', () async {
final client = FirebaseRestClient(
databaseUrl: '$_databaseUrl/',
auth: _auth(),
httpClient: http_testing.MockClient((request) async {
requests.add(request);
return _json(200, 'ok');
}),
);
requests = [];
await client.getFileText('ns/x.json');
expect(requests.single.url.toString(), startsWith('$_databaseUrl/ns/'));
});
test('close releases the HTTP client', () {
expect(_client(_json(200, null)).close, returnsNormally);
});
});
}

View File

@ -0,0 +1,164 @@
/// Tests for the shared Firebase configuration on the Dart side.
///
/// The failure modes asserted here are the ones that would otherwise surface
/// as an authentication error long after the real mistake, so each is checked
/// to name the field at fault rather than merely to throw.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _valid = {
'apiKey': 'AIzaSyExample',
'databaseUrl':
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
'projectId': 'kuhy-syncs',
'uid': 'OvA2REQyLIhAHOEjzwS1o877rgG3',
'email': 'sync@example.com',
};
/// A [RemoteStore] that does nothing, standing in for the GitHub client.
class _StubRemote implements RemoteStore {
@override
Future<List<String>> listDirectory(String path) async => [];
@override
Future<String?> getFileText(String path) async => null;
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {}
@override
Future<void> deleteFile(String path, {String message = ''}) async {}
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
void main() {
group('client construction with Google', () {
test('refuses a Google account that is not the configured uid', () async {
// End of the chain the phone actually walks: a mis-tapped account in the
// picker must fail here rather than storing a session that the security
// rules then deny on every read and write.
final store = InMemoryCredentialStore();
final mock = http_testing.MockClient(
(request) async => http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': 'a-different-persons-uid',
}),
200,
),
);
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
googleIdToken: () async => 'token-for-the-wrong-account',
httpClient: mock,
),
throwsA(isA<FirebaseAuthError>()),
);
expect(await store.load(), isNull);
});
test('falls back to the password when Google returns null', () async {
// Desktop and headless: the provider is wired but nobody is signed in
// with Google, so the machine credential has to still work.
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
password: 'hunter2',
googleIdToken: () async => null,
httpClient: mock,
);
expect(sentTo.single, contains('signInWithPassword'));
client.close();
});
test('refuses to build when Google returns null and no password', () async {
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
googleIdToken: () async => null,
),
throwsA(isA<ConfigException>()),
);
});
test('reuses a stored session without a password', () async {
final store = InMemoryCredentialStore();
await store.save(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
);
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
);
expect(client, isA<FirebaseRestClient>());
client.close();
});
test(
'mirrorStoreFor makes Firebase primary and GitHub the mirror',
() async {
// Which backend is authoritative is the one thing a rollback depends on.
final store = InMemoryCredentialStore();
await store.save(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
);
final github = _StubRemote();
final mirror = await mirrorStoreFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
githubClient: github,
);
expect(mirror.primary, isA<FirebaseRestClient>());
expect(mirror.mirror, same(github));
mirror.close();
},
);
});
}

View File

@ -0,0 +1,127 @@
/// Tests for the shared Firebase configuration on the Dart side.
///
/// The failure modes asserted here are the ones that would otherwise surface
/// as an authentication error long after the real mistake, so each is checked
/// to name the field at fault rather than merely to throw.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _valid = {
'apiKey': 'AIzaSyExample',
'databaseUrl':
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
'projectId': 'kuhy-syncs',
'uid': 'OvA2REQyLIhAHOEjzwS1o877rgG3',
'email': 'sync@example.com',
};
void main() {
group('client construction', () {
test('refuses to build without a session or a password', () async {
// Better than signing in with an empty password and failing remotely.
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
),
throwsA(isA<ConfigException>()),
);
});
test('signs in with the password when there is no session', () async {
// The first-run path: no cached refresh token, so it must exchange the
// password for one rather than failing.
final store = InMemoryCredentialStore();
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
password: 'hunter2',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithPassword'));
expect(await store.load(), isNotNull, reason: 'session must persist');
client.close();
});
test('signs in with Google when a token is available', () async {
// The one-tap path: a fresh install with no password typed anywhere.
final store = InMemoryCredentialStore();
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
googleIdToken: () async => 'google-token',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithIdp'));
expect(await store.load(), isNotNull, reason: 'session must persist');
client.close();
});
test('prefers Google over the password when both are available', () async {
// Google is the credential the user did not have to type, so on a device
// offering both it should win.
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
// Firebase always returns localId; the config carries a uid, so
// omitting it here would trip the wrong-account guard.
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
password: 'hunter2',
googleIdToken: () async => 'google-token',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithIdp'));
client.close();
});
});
}

View File

@ -8,8 +8,6 @@ library;
import 'dart:convert'; import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart'; import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart'; import 'package:test/test.dart';
const _valid = { const _valid = {
@ -21,31 +19,6 @@ const _valid = {
'email': 'sync@example.com', 'email': 'sync@example.com',
}; };
/// A [RemoteStore] that does nothing, standing in for the GitHub client.
class _StubRemote implements RemoteStore {
@override
Future<List<String>> listDirectory(String path) async => [];
@override
Future<String?> getFileText(String path) async => null;
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {}
@override
Future<void> deleteFile(String path, {String message = ''}) async {}
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
void main() { void main() {
group('FirebaseConfig', () { group('FirebaseConfig', () {
test('parses a valid config', () { test('parses a valid config', () {
@ -141,220 +114,4 @@ void main() {
expect(ConfigException('boom').toString(), contains('boom')); expect(ConfigException('boom').toString(), contains('boom'));
}); });
}); });
group('client construction', () {
test('refuses to build without a session or a password', () async {
// Better than signing in with an empty password and failing remotely.
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
),
throwsA(isA<ConfigException>()),
);
});
test('signs in with the password when there is no session', () async {
// The first-run path: no cached refresh token, so it must exchange the
// password for one rather than failing.
final store = InMemoryCredentialStore();
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
password: 'hunter2',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithPassword'));
expect(await store.load(), isNotNull, reason: 'session must persist');
client.close();
});
test('signs in with Google when a token is available', () async {
// The one-tap path: a fresh install with no password typed anywhere.
final store = InMemoryCredentialStore();
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
googleIdToken: () async => 'google-token',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithIdp'));
expect(await store.load(), isNotNull, reason: 'session must persist');
client.close();
});
test('prefers Google over the password when both are available', () async {
// Google is the credential the user did not have to type, so on a device
// offering both it should win.
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
// Firebase always returns localId; the config carries a uid, so
// omitting it here would trip the wrong-account guard.
'localId': _valid['uid'],
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
password: 'hunter2',
googleIdToken: () async => 'google-token',
httpClient: mock,
);
expect(sentTo.single, contains('signInWithIdp'));
client.close();
});
test('refuses a Google account that is not the configured uid', () async {
// End of the chain the phone actually walks: a mis-tapped account in the
// picker must fail here rather than storing a session that the security
// rules then deny on every read and write.
final store = InMemoryCredentialStore();
final mock = http_testing.MockClient(
(request) async => http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'localId': 'a-different-persons-uid',
}),
200,
),
);
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
googleIdToken: () async => 'token-for-the-wrong-account',
httpClient: mock,
),
throwsA(isA<FirebaseAuthError>()),
);
expect(await store.load(), isNull);
});
test('falls back to the password when Google returns null', () async {
// Desktop and headless: the provider is wired but nobody is signed in
// with Google, so the machine credential has to still work.
final sentTo = <String>[];
final mock = http_testing.MockClient((request) async {
sentTo.add(request.url.path);
return http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
}),
200,
);
});
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
password: 'hunter2',
googleIdToken: () async => null,
httpClient: mock,
);
expect(sentTo.single, contains('signInWithPassword'));
client.close();
});
test('refuses to build when Google returns null and no password', () async {
await expectLater(
firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: InMemoryCredentialStore(),
googleIdToken: () async => null,
),
throwsA(isA<ConfigException>()),
);
});
test('reuses a stored session without a password', () async {
final store = InMemoryCredentialStore();
await store.save(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
);
final client = await firebaseClientFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
);
expect(client, isA<FirebaseRestClient>());
client.close();
});
test(
'mirrorStoreFor makes Firebase primary and GitHub the mirror',
() async {
// Which backend is authoritative is the one thing a rollback depends on.
final store = InMemoryCredentialStore();
await store.save(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
);
final github = _StubRemote();
final mirror = await mirrorStoreFor(
config: FirebaseConfig.parse(jsonEncode(_valid)),
store: store,
githubClient: github,
);
expect(mirror.primary, isA<FirebaseRestClient>());
expect(mirror.mirror, same(github));
mirror.close();
},
);
});
} }

View File

@ -140,152 +140,4 @@ void main() {
); );
}); });
}); });
group('putFileText', () {
test('creates a new file with no sha when none existed', () async {
final client = _client([
_response(404),
_response(200),
], putResponse: _response(201));
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
});
test('updates an existing file by including its sha', () async {
final client = _client([
_response(200, {'sha': 'abc123'}),
], putResponse: _response(200));
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
});
test(
'throws RepoNotFoundError when checking sha on a missing repo',
() async {
final client = _client([_response(404), _response(404)]);
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<RepoNotFoundError>()),
);
},
);
test('throws GitHubSyncError when the sha check itself fails', () async {
final client = _client([_response(500)]);
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<GitHubSyncError>()),
);
});
test('throws GitHubSyncError on a put network exception', () async {
final client = _client([
_response(404),
_response(200),
], putError: http.ClientException('offline'));
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<GitHubSyncError>()),
);
});
test('throws GitHubSyncError on a put non-2xx response', () async {
final client = _client([
_response(404),
_response(200),
], putResponse: _response(422));
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<GitHubSyncError>()),
);
});
});
group('canAccessRepo', () {
test('is true when the repo endpoint returns 2xx', () async {
expect(await _client([_response(200)]).canAccessRepo(), isTrue);
});
test('is false when the repo is missing (404)', () async {
expect(await _client([_response(404)]).canAccessRepo(), isFalse);
});
test('is false on a network error', () async {
expect(await _client([_networkError]).canAccessRepo(), isFalse);
});
});
group('canAccessRemote', () {
// The RemoteStore spelling must behave exactly like the legacy name.
test('is true when the repo endpoint returns 2xx', () async {
expect(await _client([_response(200)]).canAccessRemote(), isTrue);
});
test('is false when the repo is missing (404)', () async {
expect(await _client([_response(404)]).canAccessRemote(), isFalse);
});
test('is false on a network error', () async {
expect(await _client([_networkError]).canAccessRemote(), isFalse);
});
});
group('RemoteStore contract', () {
test('GitHubClient is a RemoteStore', () {
expect(_client([]), isA<RemoteStore>());
});
test('RepoNotFoundError is catchable as either error type', () {
// Backend-neutral callers catch RemoteNotFoundError; existing GitHub
// callers catch GitHubSyncError. One exception must satisfy both.
final error = RepoNotFoundError('gone');
expect(error, isA<GitHubSyncError>());
expect(error, isA<RemoteNotFoundError>());
expect(error, isA<RemoteSyncError>());
});
test('errors stringify as their own type', () {
expect(GitHubSyncError('boom').toString(), 'GitHubSyncError: boom');
expect(RepoNotFoundError('gone').toString(), 'RepoNotFoundError: gone');
});
});
group('deleteFile', () {
test('deletes an existing file (resolves its own sha)', () async {
final client = _client(
[_response(200, {'sha': 'abc123'})],
deleteResponse: _response(200),
);
await expectLater(client.deleteFile('devices/pc/log.json'), completes);
});
test('is a no-op when the file does not exist', () async {
// sha lookup 404s, repo-exists check 200s -> null sha -> no DELETE sent.
final client = _client([
_response(404),
_response(200),
], deleteError: StateError('DELETE must not be called'));
await expectLater(client.deleteFile('devices/pc/gone.json'), completes);
});
test('throws GitHubSyncError on a delete non-2xx response', () async {
final client = _client(
[_response(200, {'sha': 'abc123'})],
deleteResponse: _response(500),
);
expect(
() => client.deleteFile('devices/pc/log.json'),
throwsA(isA<GitHubSyncError>()),
);
});
test('throws GitHubSyncError on a delete network exception', () async {
final client = _client(
[_response(200, {'sha': 'abc123'})],
deleteError: http.ClientException('offline'),
);
expect(
() => client.deleteFile('devices/pc/log.json'),
throwsA(isA<GitHubSyncError>()),
);
});
});
} }

View File

@ -0,0 +1,198 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
http.Response _response(int statusCode, [Object? jsonBody]) =>
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
/// Sentinel meaning "this GET throws a network exception", since a real
/// [http.Response] can't carry an invalid status code.
final _networkError = Object();
/// Builds a client whose GETs return [getResponses] in call order (each
/// either an [http.Response] or [_networkError]), and whose PUT returns
/// [putResponse] (or throws [putError] if network fails).
GitHubClient _client(
List<Object> getResponses, {
http.Response? putResponse,
Object? putError,
http.Response? deleteResponse,
Object? deleteError,
}) {
var getIndex = 0;
final mock = http_testing.MockClient((request) async {
if (request.method == 'GET') {
final response = getResponses[getIndex];
getIndex++;
if (identical(response, _networkError)) {
throw http.ClientException('offline');
}
return response as http.Response;
}
if (request.method == 'PUT') {
if (putError != null) throw putError;
return putResponse!;
}
if (request.method == 'DELETE') {
if (deleteError != null) throw deleteError;
return deleteResponse!;
}
throw UnsupportedError('unexpected method ${request.method}');
});
return GitHubClient(
owner: 'kuhyx',
repo: 'crdt-sync-demo',
token: 'fake-token',
httpClient: mock,
);
}
void main() {
group('putFileText', () {
test('creates a new file with no sha when none existed', () async {
final client = _client([
_response(404),
_response(200),
], putResponse: _response(201));
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
});
test('updates an existing file by including its sha', () async {
final client = _client([
_response(200, {'sha': 'abc123'}),
], putResponse: _response(200));
await client.putFileText('devices/pc/log.json', '{}', message: 'm');
});
test(
'throws RepoNotFoundError when checking sha on a missing repo',
() async {
final client = _client([_response(404), _response(404)]);
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<RepoNotFoundError>()),
);
},
);
test('throws GitHubSyncError when the sha check itself fails', () async {
final client = _client([_response(500)]);
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<GitHubSyncError>()),
);
});
test('throws GitHubSyncError on a put network exception', () async {
final client = _client([
_response(404),
_response(200),
], putError: http.ClientException('offline'));
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<GitHubSyncError>()),
);
});
test('throws GitHubSyncError on a put non-2xx response', () async {
final client = _client([
_response(404),
_response(200),
], putResponse: _response(422));
expect(
() => client.putFileText('devices/pc/log.json', '{}', message: 'm'),
throwsA(isA<GitHubSyncError>()),
);
});
});
group('canAccessRepo', () {
test('is true when the repo endpoint returns 2xx', () async {
expect(await _client([_response(200)]).canAccessRepo(), isTrue);
});
test('is false when the repo is missing (404)', () async {
expect(await _client([_response(404)]).canAccessRepo(), isFalse);
});
test('is false on a network error', () async {
expect(await _client([_networkError]).canAccessRepo(), isFalse);
});
});
group('canAccessRemote', () {
// The RemoteStore spelling must behave exactly like the legacy name.
test('is true when the repo endpoint returns 2xx', () async {
expect(await _client([_response(200)]).canAccessRemote(), isTrue);
});
test('is false when the repo is missing (404)', () async {
expect(await _client([_response(404)]).canAccessRemote(), isFalse);
});
test('is false on a network error', () async {
expect(await _client([_networkError]).canAccessRemote(), isFalse);
});
});
group('RemoteStore contract', () {
test('GitHubClient is a RemoteStore', () {
expect(_client([]), isA<RemoteStore>());
});
test('RepoNotFoundError is catchable as either error type', () {
// Backend-neutral callers catch RemoteNotFoundError; existing GitHub
// callers catch GitHubSyncError. One exception must satisfy both.
final error = RepoNotFoundError('gone');
expect(error, isA<GitHubSyncError>());
expect(error, isA<RemoteNotFoundError>());
expect(error, isA<RemoteSyncError>());
});
test('errors stringify as their own type', () {
expect(GitHubSyncError('boom').toString(), 'GitHubSyncError: boom');
expect(RepoNotFoundError('gone').toString(), 'RepoNotFoundError: gone');
});
});
group('deleteFile', () {
test('deletes an existing file (resolves its own sha)', () async {
final client = _client([
_response(200, {'sha': 'abc123'}),
], deleteResponse: _response(200));
await expectLater(client.deleteFile('devices/pc/log.json'), completes);
});
test('is a no-op when the file does not exist', () async {
// sha lookup 404s, repo-exists check 200s -> null sha -> no DELETE sent.
final client = _client([
_response(404),
_response(200),
], deleteError: StateError('DELETE must not be called'));
await expectLater(client.deleteFile('devices/pc/gone.json'), completes);
});
test('throws GitHubSyncError on a delete non-2xx response', () async {
final client = _client([
_response(200, {'sha': 'abc123'}),
], deleteResponse: _response(500));
expect(
() => client.deleteFile('devices/pc/log.json'),
throwsA(isA<GitHubSyncError>()),
);
});
test('throws GitHubSyncError on a delete network exception', () async {
final client = _client([
_response(200, {'sha': 'abc123'}),
], deleteError: http.ClientException('offline'));
expect(
() => client.deleteFile('devices/pc/log.json'),
throwsA(isA<GitHubSyncError>()),
);
});
});
}

View File

@ -0,0 +1,196 @@
/// Tests for the dual-write decorator used during the GitHub -> Firebase
/// cutover.
///
/// The asymmetry is the whole point and is what these assert: a primary
/// failure must fail the tick, a mirror failure must not, and reads must
/// consult both so that a half-migrated app (one device moved, one not) still
/// converges in both directions.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
/// A scriptable in-memory [RemoteStore] that can be told to fail.
class _FakeStore implements RemoteStore, BulkMapReader {
_FakeStore({Map<String, String>? files, this.failing = false})
: files = files ?? {};
final Map<String, String> files;
bool failing;
final List<String> writes = [];
bool closed = false;
void _guard(String what) {
if (failing) throw RemoteSyncError('$what failed');
}
@override
Future<List<String>> listDirectory(String path) async {
_guard('list');
return files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
}
@override
Future<String?> getFileText(String path) async {
_guard('read');
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
_guard('write');
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
_guard('map read');
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async {
_guard('delete');
files.remove(path);
}
@override
Future<bool> canAccessRemote() async => !failing;
@override
void close() => closed = true;
}
/// A store with no bulk-map capability, standing in for [GitHubClient].
class _FakeStoreWithoutBulkRead implements RemoteStore {
_FakeStoreWithoutBulkRead([Map<String, String>? files])
: _inner = _FakeStore(files: files);
final _FakeStore _inner;
@override
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(String p, String t, {required String message}) =>
_inner.putFileText(p, t, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
({
MirrorStore store,
_FakeStore primary,
_FakeStore mirror,
List<String> failures,
})
_mirror({
Map<String, String>? primaryFiles,
Map<String, String>? mirrorFiles,
bool primaryFailing = false,
bool mirrorFailing = false,
}) {
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
final failures = <String>[];
return (
store: MirrorStore(
primary: primary,
mirror: mirror,
onMirrorFailure: (operation, _) => failures.add(operation),
),
primary: primary,
mirror: mirror,
failures: failures,
);
}
void main() {
group('primary read fallback', () {
test('getFileText falls back to the mirror', () async {
final m = _mirror(
primaryFailing: true,
mirrorFiles: {'ns/phone/log.json': '{}'},
);
expect(await m.store.getFileText('ns/phone/log.json'), '{}');
});
test('getFileText throws when both fail', () async {
final m = _mirror(primaryFailing: true, mirrorFailing: true);
await expectLater(
() => m.store.getFileText('ns/phone/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
test('getFileText throws when primary fails and mirror lacks it', () async {
// A reachable mirror that simply does not hold the file is not an
// answer either, since the primary's copy was never read.
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.getFileText('ns/phone/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
test('getStringMap falls back to the mirror', () async {
final m = _mirror(
primaryFailing: true,
mirrorFiles: {'ns/revs/phone': 'r1'},
);
expect(await m.store.getStringMap('ns/revs'), {'phone': 'r1'});
});
test('getStringMap throws when both fail', () async {
final m = _mirror(primaryFailing: true, mirrorFailing: true);
await expectLater(
() => m.store.getStringMap('ns/revs'),
throwsA(isA<RemoteSyncError>()),
);
});
test('getStringMap tolerates a mirror that cannot bulk-read', () async {
// A mirror with no bulk-read capability (GitHub) contributes nothing,
// but that is not a failure -- it simply has no revisions to add, so a
// working primary's map must still come back.
final store = MirrorStore(
primary: _FakeStore(files: {'ns/revs/pc': 'r1'}),
mirror: _FakeStoreWithoutBulkRead(),
);
expect(await store.getStringMap('ns/revs'), {'pc': 'r1'});
});
test('writes stay fail-closed on a primary failure', () async {
// The read fix must NOT loosen writes: an unaccepted write has not
// happened, so it must still fail the tick.
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.putFileText('ns/pc/log.json', '{}', message: 'm'),
throwsA(isA<RemoteSyncError>()),
);
await expectLater(
() => m.store.deleteFile('ns/pc/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
});
}

View File

@ -0,0 +1,153 @@
/// Tests for the dual-write decorator used during the GitHub -> Firebase
/// cutover.
///
/// The asymmetry is the whole point and is what these assert: a primary
/// failure must fail the tick, a mirror failure must not, and reads must
/// consult both so that a half-migrated app (one device moved, one not) still
/// converges in both directions.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
/// A scriptable in-memory [RemoteStore] that can be told to fail.
class _FakeStore implements RemoteStore, BulkMapReader {
_FakeStore({Map<String, String>? files, this.failing = false})
: files = files ?? {};
final Map<String, String> files;
bool failing;
final List<String> writes = [];
bool closed = false;
void _guard(String what) {
if (failing) throw RemoteSyncError('$what failed');
}
@override
Future<List<String>> listDirectory(String path) async {
_guard('list');
return files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
}
@override
Future<String?> getFileText(String path) async {
_guard('read');
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
_guard('write');
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
_guard('map read');
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async {
_guard('delete');
files.remove(path);
}
@override
Future<bool> canAccessRemote() async => !failing;
@override
void close() => closed = true;
}
({
MirrorStore store,
_FakeStore primary,
_FakeStore mirror,
List<String> failures,
})
_mirror({
Map<String, String>? primaryFiles,
Map<String, String>? mirrorFiles,
bool primaryFailing = false,
bool mirrorFailing = false,
}) {
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
final failures = <String>[];
return (
store: MirrorStore(
primary: primary,
mirror: mirror,
onMirrorFailure: (operation, _) => failures.add(operation),
),
primary: primary,
mirror: mirror,
failures: failures,
);
}
void main() {
group('listDirectory', () {
test('unions devices from both backends', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFiles: {'ns/phone/log.json': '{}'},
);
expect(await m.store.listDirectory('ns'), containsAll(['pc', 'phone']));
});
test('does not duplicate a device present in both', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFiles: {'ns/pc/log.json': '{}'},
);
expect(await m.store.listDirectory('ns'), ['pc']);
});
test('a mirror failure degrades to the primary list', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFailing: true,
);
expect(await m.store.listDirectory('ns'), ['pc']);
expect(m.failures, ['listDirectory ns']);
});
test('a primary failure degrades to the mirror list', () async {
// READS are resilient on both sides: a Firebase outage must not hide
// the mirror's devices. This used to throw, which made a primary outage
// look like "no devices exist" -- the union read silently degrading to
// nothing, precisely when the fallback was needed.
final m = _mirror(
primaryFailing: true,
mirrorFiles: {'ns/phone/log.json': '{}'},
);
expect(await m.store.listDirectory('ns'), ['phone']);
});
test('both backends failing throws', () async {
// With no answer from either side, fail closed: an empty list is
// indistinguishable from "no devices", and callers act on that.
final m = _mirror(primaryFailing: true, mirrorFailing: true);
await expectLater(
() => m.store.listDirectory('ns'),
throwsA(isA<RemoteSyncError>()),
);
});
});
}

View File

@ -0,0 +1,248 @@
/// Tests for the dual-write decorator used during the GitHub -> Firebase
/// cutover.
///
/// The asymmetry is the whole point and is what these assert: a primary
/// failure must fail the tick, a mirror failure must not, and reads must
/// consult both so that a half-migrated app (one device moved, one not) still
/// converges in both directions.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
/// A scriptable in-memory [RemoteStore] that can be told to fail.
class _FakeStore implements RemoteStore, BulkMapReader {
_FakeStore({Map<String, String>? files, this.failing = false})
: files = files ?? {};
final Map<String, String> files;
bool failing;
final List<String> writes = [];
bool closed = false;
void _guard(String what) {
if (failing) throw RemoteSyncError('$what failed');
}
@override
Future<List<String>> listDirectory(String path) async {
_guard('list');
return files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
}
@override
Future<String?> getFileText(String path) async {
_guard('read');
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
_guard('write');
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
_guard('map read');
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async {
_guard('delete');
files.remove(path);
}
@override
Future<bool> canAccessRemote() async => !failing;
@override
void close() => closed = true;
}
/// A store with no bulk-map capability, standing in for [GitHubClient].
class _FakeStoreWithoutBulkRead implements RemoteStore {
_FakeStoreWithoutBulkRead([Map<String, String>? files])
: _inner = _FakeStore(files: files);
final _FakeStore _inner;
@override
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(String p, String t, {required String message}) =>
_inner.putFileText(p, t, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
({
MirrorStore store,
_FakeStore primary,
_FakeStore mirror,
List<String> failures,
})
_mirror({
Map<String, String>? primaryFiles,
Map<String, String>? mirrorFiles,
bool primaryFailing = false,
bool mirrorFailing = false,
}) {
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
final failures = <String>[];
return (
store: MirrorStore(
primary: primary,
mirror: mirror,
onMirrorFailure: (operation, _) => failures.add(operation),
),
primary: primary,
mirror: mirror,
failures: failures,
);
}
void main() {
group('revision maps', () {
test('merge both backends, primary winning on conflict', () async {
final m = _mirror(
primaryFiles: {'ns/revs/pc': 'primary-rev'},
mirrorFiles: {'ns/revs/pc': 'mirror-rev', 'ns/revs/phone': 'old-rev'},
);
expect(await m.store.getStringMap('ns/revs'), {
'pc': 'primary-rev',
// An un-migrated device publishes revisions only to the mirror;
// without this it would look revision-less and be re-downloaded
// every tick for the whole trial.
'phone': 'old-rev',
});
});
test('a mirror failure degrades to primary revisions only', () async {
final m = _mirror(
primaryFiles: {'ns/revs/pc': 'primary-rev'},
mirrorFailing: true,
);
expect(await m.store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
expect(m.failures, ['getStringMap ns/revs']);
});
test('a backend without bulk reads simply contributes nothing', () async {
final store = MirrorStore(
primary: _FakeStore(files: {'ns/revs/pc': 'primary-rev'}),
mirror: _FakeStoreWithoutBulkRead({'ns/revs/phone': 'ignored'}),
);
expect(await store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
});
test('is empty when the primary has no bulk reads either', () async {
final store = MirrorStore(
primary: _FakeStoreWithoutBulkRead(),
mirror: _FakeStoreWithoutBulkRead(),
);
expect(await store.getStringMap('ns/revs'), isEmpty);
});
});
group('lifecycle', () {
test('canAccessRemote reports only the primary', () async {
// A Test-connection button must not report success because the backend
// being retired happens to answer.
final m = _mirror(primaryFailing: true);
expect(await m.store.canAccessRemote(), isFalse);
final healthy = _mirror(mirrorFailing: true);
expect(await healthy.store.canAccessRemote(), isTrue);
});
test('close releases both backends', () {
final m = _mirror();
m.store.close();
expect(m.primary.closed, isTrue);
expect(m.mirror.closed, isTrue);
});
test('a mirror failure with no handler is still swallowed', () async {
final store = MirrorStore(
primary: _FakeStore(),
mirror: _FakeStore(failing: true),
);
await expectLater(
store.putFileText('ns/pc/log.json', '{}', message: 'm'),
completes,
);
});
test('is itself a RemoteStore, so syncLog accepts it', () {
expect(_mirror().store, isA<RemoteStore>());
});
});
group('end to end through syncLog', () {
test('a half-migrated pair still converges both ways', () async {
// desktop is on Firebase+mirror; phone has not migrated and still
// writes only to GitHub. Both must end up with both records.
final github = _FakeStore();
final firebase = _FakeStore();
Log log(String id, String nodeId) => {
id: Record(
id: id,
fields: {
'v': (id, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
// The library's own serialization, so this exercises the same
// encoding the apps use rather than a test-only one.
const encode = logToJson;
const decode = logFromJson;
// Un-migrated phone: GitHub only.
await syncLog(
client: github,
deviceId: 'phone',
pathPrefix: 'ns/devices',
localLog: log('from-phone', 'node-phone'),
encode: encode,
decode: decode,
);
// Migrated desktop: Firebase primary, GitHub mirror.
final merged = await syncLog(
client: MirrorStore(primary: firebase, mirror: github),
deviceId: 'desktop',
pathPrefix: 'ns/devices',
localLog: log('from-desktop', 'node-desktop'),
encode: encode,
decode: decode,
);
expect(merged.keys, containsAll(['from-phone', 'from-desktop']));
// The desktop's merged result is mirrored back to GitHub, so the
// un-migrated phone sees it on its next tick.
expect(github.files['ns/devices/desktop/log.json'], isNotNull);
});
});
}

View File

@ -74,29 +74,6 @@ class _FakeStore implements RemoteStore, BulkMapReader {
void close() => closed = true; void close() => closed = true;
} }
/// A store with no bulk-map capability, standing in for [GitHubClient].
class _FakeStoreWithoutBulkRead implements RemoteStore {
_FakeStoreWithoutBulkRead([Map<String, String>? files])
: _inner = _FakeStore(files: files);
final _FakeStore _inner;
@override
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(String p, String t, {required String message}) =>
_inner.putFileText(p, t, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
({ ({
MirrorStore store, MirrorStore store,
_FakeStore primary, _FakeStore primary,
@ -210,243 +187,4 @@ void main() {
); );
}); });
}); });
group('listDirectory', () {
test('unions devices from both backends', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFiles: {'ns/phone/log.json': '{}'},
);
expect(await m.store.listDirectory('ns'), containsAll(['pc', 'phone']));
});
test('does not duplicate a device present in both', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFiles: {'ns/pc/log.json': '{}'},
);
expect(await m.store.listDirectory('ns'), ['pc']);
});
test('a mirror failure degrades to the primary list', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFailing: true,
);
expect(await m.store.listDirectory('ns'), ['pc']);
expect(m.failures, ['listDirectory ns']);
});
test('a primary failure degrades to the mirror list', () async {
// READS are resilient on both sides: a Firebase outage must not hide
// the mirror's devices. This used to throw, which made a primary outage
// look like "no devices exist" -- the union read silently degrading to
// nothing, precisely when the fallback was needed.
final m = _mirror(
primaryFailing: true,
mirrorFiles: {'ns/phone/log.json': '{}'},
);
expect(await m.store.listDirectory('ns'), ['phone']);
});
test('both backends failing throws', () async {
// With no answer from either side, fail closed: an empty list is
// indistinguishable from "no devices", and callers act on that.
final m = _mirror(primaryFailing: true, mirrorFailing: true);
await expectLater(
() => m.store.listDirectory('ns'),
throwsA(isA<RemoteSyncError>()),
);
});
});
group('primary read fallback', () {
test('getFileText falls back to the mirror', () async {
final m = _mirror(
primaryFailing: true,
mirrorFiles: {'ns/phone/log.json': '{}'},
);
expect(await m.store.getFileText('ns/phone/log.json'), '{}');
});
test('getFileText throws when both fail', () async {
final m = _mirror(primaryFailing: true, mirrorFailing: true);
await expectLater(
() => m.store.getFileText('ns/phone/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
test('getFileText throws when primary fails and mirror lacks it', () async {
// A reachable mirror that simply does not hold the file is not an
// answer either, since the primary's copy was never read.
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.getFileText('ns/phone/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
test('getStringMap falls back to the mirror', () async {
final m = _mirror(
primaryFailing: true,
mirrorFiles: {'ns/revs/phone': 'r1'},
);
expect(await m.store.getStringMap('ns/revs'), {'phone': 'r1'});
});
test('getStringMap throws when both fail', () async {
final m = _mirror(primaryFailing: true, mirrorFailing: true);
await expectLater(
() => m.store.getStringMap('ns/revs'),
throwsA(isA<RemoteSyncError>()),
);
});
test('getStringMap tolerates a mirror that cannot bulk-read', () async {
// A mirror with no bulk-read capability (GitHub) contributes nothing,
// but that is not a failure -- it simply has no revisions to add, so a
// working primary's map must still come back.
final store = MirrorStore(
primary: _FakeStore(files: {'ns/revs/pc': 'r1'}),
mirror: _FakeStoreWithoutBulkRead(),
);
expect(await store.getStringMap('ns/revs'), {'pc': 'r1'});
});
test('writes stay fail-closed on a primary failure', () async {
// The read fix must NOT loosen writes: an unaccepted write has not
// happened, so it must still fail the tick.
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.putFileText('ns/pc/log.json', '{}', message: 'm'),
throwsA(isA<RemoteSyncError>()),
);
await expectLater(
() => m.store.deleteFile('ns/pc/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
});
group('revision maps', () {
test('merge both backends, primary winning on conflict', () async {
final m = _mirror(
primaryFiles: {'ns/revs/pc': 'primary-rev'},
mirrorFiles: {'ns/revs/pc': 'mirror-rev', 'ns/revs/phone': 'old-rev'},
);
expect(await m.store.getStringMap('ns/revs'), {
'pc': 'primary-rev',
// An un-migrated device publishes revisions only to the mirror;
// without this it would look revision-less and be re-downloaded
// every tick for the whole trial.
'phone': 'old-rev',
});
});
test('a mirror failure degrades to primary revisions only', () async {
final m = _mirror(
primaryFiles: {'ns/revs/pc': 'primary-rev'},
mirrorFailing: true,
);
expect(await m.store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
expect(m.failures, ['getStringMap ns/revs']);
});
test('a backend without bulk reads simply contributes nothing', () async {
final store = MirrorStore(
primary: _FakeStore(files: {'ns/revs/pc': 'primary-rev'}),
mirror: _FakeStoreWithoutBulkRead({'ns/revs/phone': 'ignored'}),
);
expect(await store.getStringMap('ns/revs'), {'pc': 'primary-rev'});
});
test('is empty when the primary has no bulk reads either', () async {
final store = MirrorStore(
primary: _FakeStoreWithoutBulkRead(),
mirror: _FakeStoreWithoutBulkRead(),
);
expect(await store.getStringMap('ns/revs'), isEmpty);
});
});
group('lifecycle', () {
test('canAccessRemote reports only the primary', () async {
// A Test-connection button must not report success because the backend
// being retired happens to answer.
final m = _mirror(primaryFailing: true);
expect(await m.store.canAccessRemote(), isFalse);
final healthy = _mirror(mirrorFailing: true);
expect(await healthy.store.canAccessRemote(), isTrue);
});
test('close releases both backends', () {
final m = _mirror();
m.store.close();
expect(m.primary.closed, isTrue);
expect(m.mirror.closed, isTrue);
});
test('a mirror failure with no handler is still swallowed', () async {
final store = MirrorStore(
primary: _FakeStore(),
mirror: _FakeStore(failing: true),
);
await expectLater(
store.putFileText('ns/pc/log.json', '{}', message: 'm'),
completes,
);
});
test('is itself a RemoteStore, so syncLog accepts it', () {
expect(_mirror().store, isA<RemoteStore>());
});
});
group('end to end through syncLog', () {
test('a half-migrated pair still converges both ways', () async {
// desktop is on Firebase+mirror; phone has not migrated and still
// writes only to GitHub. Both must end up with both records.
final github = _FakeStore();
final firebase = _FakeStore();
Log log(String id, String nodeId) => {
id: Record(
id: id,
fields: {
'v': (id, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
// The library's own serialization, so this exercises the same
// encoding the apps use rather than a test-only one.
const encode = logToJson;
const decode = logFromJson;
// Un-migrated phone: GitHub only.
await syncLog(
client: github,
deviceId: 'phone',
pathPrefix: 'ns/devices',
localLog: log('from-phone', 'node-phone'),
encode: encode,
decode: decode,
);
// Migrated desktop: Firebase primary, GitHub mirror.
final merged = await syncLog(
client: MirrorStore(primary: firebase, mirror: github),
deviceId: 'desktop',
pathPrefix: 'ns/devices',
localLog: log('from-desktop', 'node-desktop'),
encode: encode,
decode: decode,
);
expect(merged.keys, containsAll(['from-phone', 'from-desktop']));
// The desktop's merged result is mirrored back to GitHub, so the
// un-migrated phone sees it on its next tick.
expect(github.files['ns/devices/desktop/log.json'], isNotNull);
});
});
} }

View File

@ -0,0 +1,158 @@
/// Tests for the revision tracking that keeps sync inside the free tier.
///
/// Two savings, both measured against the GitHub-backed history this
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
/// re-downloaded every peer's whole log regardless of whether it had changed.
/// These tests assert the *request counts*, since that -- not the merge
/// result -- is what the free-tier headroom depends on.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
id: Record(
id: id,
fields: {
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// An in-memory [RemoteStore] that also serves revision maps in one read,
/// standing in for [FirebaseRestClient]. Counts every call so tests can
/// assert on traffic rather than on results.
class _FakeRemote implements RemoteStore, BulkMapReader {
_FakeRemote(this.files);
final Map<String, String> files;
final List<String> reads = [];
final List<String> writes = [];
final List<String> mapReads = [];
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
Future<String?> getFileText(String path) async {
reads.add(path);
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
mapReads.add(path);
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
/// A [RemoteStore] with no bulk-map capability, standing in for
/// [GitHubClient]: revision tracking must degrade, not break.
///
/// Delegates rather than extends, because a subclass of [_FakeRemote] would
/// inherit `BulkMapReader` -- the very capability this fake exists to lack.
class _FakeRemoteWithoutBulkRead implements RemoteStore {
_FakeRemoteWithoutBulkRead(Map<String, String> files)
: _inner = _FakeRemote(files);
final _FakeRemote _inner;
Map<String, String> get files => _inner.files;
List<String> get reads => _inner.reads;
List<String> get writes => _inner.writes;
@override
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) => _inner.putFileText(path, text, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
Future<Log> _tick(
RemoteStore remote,
SyncStateStore? store, {
Log local = const {},
String deviceId = 'pc',
}) => syncLog(
client: remote,
deviceId: deviceId,
pathPrefix: 'ns/devices',
localLog: local,
encode: _encode,
decode: _decode,
stateStore: store,
);
void main() {
group('backends without bulk-map reads', () {
test('still sync correctly, just without the saving', () async {
// GitHubClient has no BulkMapReader, so revision lookup degrades to
// "fetch everything" -- correctness must not depend on the optimisation.
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemoteWithoutBulkRead({
'ns/devices/phone/log.json': peer,
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
expect(first.keys, contains('b'));
remote.reads.clear();
await _tick(remote, store, local: first);
expect(remote.reads, contains('ns/devices/phone/log.json'));
});
});
}

View File

@ -0,0 +1,125 @@
/// Tests for the revision tracking that keeps sync inside the free tier.
///
/// Two savings, both measured against the GitHub-backed history this
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
/// re-downloaded every peer's whole log regardless of whether it had changed.
/// These tests assert the *request counts*, since that -- not the merge
/// result -- is what the free-tier headroom depends on.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
id: Record(
id: id,
fields: {
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// An in-memory [RemoteStore] that also serves revision maps in one read,
/// standing in for [FirebaseRestClient]. Counts every call so tests can
/// assert on traffic rather than on results.
class _FakeRemote implements RemoteStore, BulkMapReader {
_FakeRemote(this.files);
final Map<String, String> files;
final List<String> reads = [];
final List<String> writes = [];
final List<String> mapReads = [];
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
Future<String?> getFileText(String path) async {
reads.add(path);
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
mapReads.add(path);
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
Future<Log> _tick(
RemoteStore remote,
SyncStateStore? store, {
Log local = const {},
String deviceId = 'pc',
}) => syncLog(
client: remote,
deviceId: deviceId,
pathPrefix: 'ns/devices',
localLog: local,
encode: _encode,
decode: _decode,
stateStore: store,
);
void main() {
group('revision publishing order', () {
test('publishes the log before its revision', () async {
// Reversed, a peer would cache "seen rev X" against a log it never
// received, and skip it forever.
final remote = _FakeRemote({});
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
expect(
remote.writes.indexOf('ns/devices/pc/log.json'),
lessThan(remote.writes.indexOf('ns/revs/pc')),
);
});
test('each device writes only its own revision key', () async {
// Per-device keys rather than one shared map: a whole-map write would
// erase every other device's entry, after which those peers would look
// permanently unchanged and never be fetched again.
final remote = _FakeRemote({'ns/revs/phone': 'peer-rev'});
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
expect(remote.files['ns/revs/phone'], 'peer-rev');
});
});
group('PersistedSyncStateStore', () {});
}

View File

@ -0,0 +1,193 @@
/// Tests for the revision tracking that keeps sync inside the free tier.
///
/// Two savings, both measured against the GitHub-backed history this
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
/// re-downloaded every peer's whole log regardless of whether it had changed.
/// These tests assert the *request counts*, since that -- not the merge
/// result -- is what the free-tier headroom depends on.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
id: Record(
id: id,
fields: {
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// An in-memory [RemoteStore] that also serves revision maps in one read,
/// standing in for [FirebaseRestClient]. Counts every call so tests can
/// assert on traffic rather than on results.
class _FakeRemote implements RemoteStore, BulkMapReader {
_FakeRemote(this.files);
final Map<String, String> files;
final List<String> reads = [];
final List<String> writes = [];
final List<String> mapReads = [];
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
Future<String?> getFileText(String path) async {
reads.add(path);
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
mapReads.add(path);
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
Future<Log> _tick(
RemoteStore remote,
SyncStateStore? store, {
Log local = const {},
String deviceId = 'pc',
}) => syncLog(
client: remote,
deviceId: deviceId,
pathPrefix: 'ns/devices',
localLog: local,
encode: _encode,
decode: _decode,
stateStore: store,
);
void main() {
group('peer download suppression', () {
test('downloads a peer whose revision it has never seen', () async {
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peer,
'ns/revs/phone': revisionOf(peer),
});
final merged = await _tick(remote, InMemorySyncStateStore());
expect(remote.reads, contains('ns/devices/phone/log.json'));
expect(merged.keys, contains('b'));
});
test('skips the download when the peer revision is unchanged', () async {
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peer,
'ns/revs/phone': revisionOf(peer),
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
remote.reads.clear();
// The peer's records are already in the local log from tick one, so
// re-downloading them is pure waste -- this is the ~700 MB/month that
// the revision check removes.
await _tick(remote, store, local: first);
expect(remote.reads, isEmpty);
});
test('downloads again once the peer publishes a new revision', () async {
final peerV1 = _encode(_log('b', 'v1', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peerV1,
'ns/revs/phone': revisionOf(peerV1),
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
remote.reads.clear();
final peerV2 = _encode({
..._decode(peerV1),
..._log('c', 'v2', nodeId: 'node-b'),
});
remote.files['ns/devices/phone/log.json'] = peerV2;
remote.files['ns/revs/phone'] = revisionOf(peerV2);
final second = await _tick(remote, store, local: first);
expect(remote.reads, contains('ns/devices/phone/log.json'));
expect(second.keys, containsAll(['b', 'c']));
});
test('re-downloads a peer whose push was corrupt', () async {
// A failed decode must not be remembered as seen, or the corruption
// would be permanent.
final remote = _FakeRemote({
'ns/devices/phone/log.json': 'not json at all',
'ns/revs/phone': revisionOf('not json at all'),
});
final store = InMemorySyncStateStore();
await _tick(remote, store);
remote.reads.clear();
await _tick(remote, store);
expect(remote.reads, contains('ns/devices/phone/log.json'));
});
test('downloads when the peer has published no revision yet', () async {
// A device still running the pre-migration code publishes a log but no
// revision; it must not be silently ignored.
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({'ns/devices/phone/log.json': peer});
final store = InMemorySyncStateStore();
final merged = await _tick(remote, store);
expect(merged.keys, contains('b'));
});
test('skips a peer that has nothing pushed yet', () async {
final remote = _FakeRemote({'ns/devices/phone/other.txt': 'x'});
final merged = await _tick(remote, InMemorySyncStateStore());
expect(merged, isEmpty);
});
test('never reads its own device back', () async {
final remote = _FakeRemote({
'ns/devices/pc/log.json': _encode(_log('a', '1')),
'ns/revs/pc': 'whatever',
});
await _tick(remote, InMemorySyncStateStore());
expect(remote.reads, isEmpty);
});
});
}

View File

@ -0,0 +1,193 @@
/// Tests for the revision tracking that keeps sync inside the free tier.
///
/// Two savings, both measured against the GitHub-backed history this
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
/// re-downloaded every peer's whole log regardless of whether it had changed.
/// These tests assert the *request counts*, since that -- not the merge
/// result -- is what the free-tier headroom depends on.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
id: Record(
id: id,
fields: {
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// An in-memory [RemoteStore] that also serves revision maps in one read,
/// standing in for [FirebaseRestClient]. Counts every call so tests can
/// assert on traffic rather than on results.
class _FakeRemote implements RemoteStore, BulkMapReader {
_FakeRemote(this.files);
final Map<String, String> files;
final List<String> reads = [];
final List<String> writes = [];
final List<String> mapReads = [];
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
Future<String?> getFileText(String path) async {
reads.add(path);
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
mapReads.add(path);
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
Future<Log> _tick(
RemoteStore remote,
SyncStateStore? store, {
Log local = const {},
String deviceId = 'pc',
}) => syncLog(
client: remote,
deviceId: deviceId,
pathPrefix: 'ns/devices',
localLog: local,
encode: _encode,
decode: _decode,
stateStore: store,
);
void main() {
group('PersistedSyncStateStore', () {
test('round-trips state through persistence', () async {
final persistence = _FakePersistence();
const state = SyncState(
pushedRev: 'rev-local',
peerRevs: {'phone': 'rev-phone'},
);
await PersistedSyncStateStore(persistence).save(state);
final loaded = await PersistedSyncStateStore(persistence).load();
// A *separate* store instance reads it back: that is the whole point --
// surviving the cold start that an in-memory store does not.
expect(loaded.pushedRev, 'rev-local');
expect(loaded.peerRevs, {'phone': 'rev-phone'});
});
test('survives a cold start, so peers are not re-downloaded', () async {
// The free-tier claim on mobile rests on this: a fresh process must
// still skip an unchanged peer.
final persistence = _FakePersistence();
final remote = _FakeRemote({
'ns/devices/phone/log.json': _encode(_log('a', '1')),
'ns/revs/phone': revisionOf(_encode(_log('a', '1'))),
});
await _tick(remote, PersistedSyncStateStore(persistence));
remote.reads.clear();
await _tick(remote, PersistedSyncStateStore(persistence));
expect(remote.reads, isNot(contains('ns/devices/phone/log.json')));
});
test('reports nothing remembered when absent', () async {
final loaded = await PersistedSyncStateStore(_FakePersistence()).load();
expect(loaded.pushedRev, isNull);
expect(loaded.peerRevs, isEmpty);
});
test('degrades to remembering nothing when empty', () async {
final loaded = await PersistedSyncStateStore(
_FakePersistence(text: ''),
).load();
expect(loaded.pushedRev, isNull);
});
test('degrades to remembering nothing when corrupt', () async {
// Costs one tick of extra traffic rather than failing the sync.
final loaded = await PersistedSyncStateStore(
_FakePersistence(text: 'not json{'),
).load();
expect(loaded.pushedRev, isNull);
});
test('degrades to remembering nothing when not an object', () async {
final loaded = await PersistedSyncStateStore(
_FakePersistence(text: '["a list"]'),
).load();
expect(loaded.pushedRev, isNull);
});
test('degrades to remembering nothing when the read throws', () async {
// An unreadable file must not fail the tick.
final loaded = await PersistedSyncStateStore(
_FakePersistence(readThrows: true),
).load();
expect(loaded.pushedRev, isNull);
});
});
}
/// A [LogPersistence] fake with injectable failure modes.
class _FakePersistence implements LogPersistence {
_FakePersistence({this.text, this.readThrows = false});
String? text;
final bool readThrows;
@override
Future<String?> read() async {
if (readThrows) throw const FormatException('unreadable');
return text;
}
@override
Future<void> write(String value) async => text = value;
}

View File

@ -83,45 +83,6 @@ class _FakeRemote implements RemoteStore, BulkMapReader {
void close() {} void close() {}
} }
/// A [RemoteStore] with no bulk-map capability, standing in for
/// [GitHubClient]: revision tracking must degrade, not break.
///
/// Delegates rather than extends, because a subclass of [_FakeRemote] would
/// inherit `BulkMapReader` -- the very capability this fake exists to lack.
class _FakeRemoteWithoutBulkRead implements RemoteStore {
_FakeRemoteWithoutBulkRead(Map<String, String> files)
: _inner = _FakeRemote(files);
final _FakeRemote _inner;
Map<String, String> get files => _inner.files;
List<String> get reads => _inner.reads;
List<String> get writes => _inner.writes;
@override
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) => _inner.putFileText(path, text, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
Future<Log> _tick( Future<Log> _tick(
RemoteStore remote, RemoteStore remote,
SyncStateStore? store, { SyncStateStore? store, {
@ -224,228 +185,4 @@ void main() {
expect(remote.writes.where((w) => w.startsWith('ns/revs')), isEmpty); expect(remote.writes.where((w) => w.startsWith('ns/revs')), isEmpty);
}); });
}); });
group('peer download suppression', () {
test('downloads a peer whose revision it has never seen', () async {
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peer,
'ns/revs/phone': revisionOf(peer),
});
final merged = await _tick(remote, InMemorySyncStateStore());
expect(remote.reads, contains('ns/devices/phone/log.json'));
expect(merged.keys, contains('b'));
});
test('skips the download when the peer revision is unchanged', () async {
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peer,
'ns/revs/phone': revisionOf(peer),
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
remote.reads.clear();
// The peer's records are already in the local log from tick one, so
// re-downloading them is pure waste -- this is the ~700 MB/month that
// the revision check removes.
await _tick(remote, store, local: first);
expect(remote.reads, isEmpty);
});
test('downloads again once the peer publishes a new revision', () async {
final peerV1 = _encode(_log('b', 'v1', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peerV1,
'ns/revs/phone': revisionOf(peerV1),
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
remote.reads.clear();
final peerV2 = _encode({
..._decode(peerV1),
..._log('c', 'v2', nodeId: 'node-b'),
});
remote.files['ns/devices/phone/log.json'] = peerV2;
remote.files['ns/revs/phone'] = revisionOf(peerV2);
final second = await _tick(remote, store, local: first);
expect(remote.reads, contains('ns/devices/phone/log.json'));
expect(second.keys, containsAll(['b', 'c']));
});
test('re-downloads a peer whose push was corrupt', () async {
// A failed decode must not be remembered as seen, or the corruption
// would be permanent.
final remote = _FakeRemote({
'ns/devices/phone/log.json': 'not json at all',
'ns/revs/phone': revisionOf('not json at all'),
});
final store = InMemorySyncStateStore();
await _tick(remote, store);
remote.reads.clear();
await _tick(remote, store);
expect(remote.reads, contains('ns/devices/phone/log.json'));
});
test('downloads when the peer has published no revision yet', () async {
// A device still running the pre-migration code publishes a log but no
// revision; it must not be silently ignored.
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({'ns/devices/phone/log.json': peer});
final store = InMemorySyncStateStore();
final merged = await _tick(remote, store);
expect(merged.keys, contains('b'));
});
test('skips a peer that has nothing pushed yet', () async {
final remote = _FakeRemote({'ns/devices/phone/other.txt': 'x'});
final merged = await _tick(remote, InMemorySyncStateStore());
expect(merged, isEmpty);
});
test('never reads its own device back', () async {
final remote = _FakeRemote({
'ns/devices/pc/log.json': _encode(_log('a', '1')),
'ns/revs/pc': 'whatever',
});
await _tick(remote, InMemorySyncStateStore());
expect(remote.reads, isEmpty);
});
});
group('backends without bulk-map reads', () {
test('still sync correctly, just without the saving', () async {
// GitHubClient has no BulkMapReader, so revision lookup degrades to
// "fetch everything" -- correctness must not depend on the optimisation.
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemoteWithoutBulkRead({
'ns/devices/phone/log.json': peer,
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
expect(first.keys, contains('b'));
remote.reads.clear();
await _tick(remote, store, local: first);
expect(remote.reads, contains('ns/devices/phone/log.json'));
});
});
group('revision publishing order', () {
test('publishes the log before its revision', () async {
// Reversed, a peer would cache "seen rev X" against a log it never
// received, and skip it forever.
final remote = _FakeRemote({});
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
expect(
remote.writes.indexOf('ns/devices/pc/log.json'),
lessThan(remote.writes.indexOf('ns/revs/pc')),
);
});
test('each device writes only its own revision key', () async {
// Per-device keys rather than one shared map: a whole-map write would
// erase every other device's entry, after which those peers would look
// permanently unchanged and never be fetched again.
final remote = _FakeRemote({'ns/revs/phone': 'peer-rev'});
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
expect(remote.files['ns/revs/phone'], 'peer-rev');
});
});
group('PersistedSyncStateStore', () {
test('round-trips state through persistence', () async {
final persistence = _FakePersistence();
const state = SyncState(
pushedRev: 'rev-local',
peerRevs: {'phone': 'rev-phone'},
);
await PersistedSyncStateStore(persistence).save(state);
final loaded = await PersistedSyncStateStore(persistence).load();
// A *separate* store instance reads it back: that is the whole point --
// surviving the cold start that an in-memory store does not.
expect(loaded.pushedRev, 'rev-local');
expect(loaded.peerRevs, {'phone': 'rev-phone'});
});
test('survives a cold start, so peers are not re-downloaded', () async {
// The free-tier claim on mobile rests on this: a fresh process must
// still skip an unchanged peer.
final persistence = _FakePersistence();
final remote = _FakeRemote({
'ns/devices/phone/log.json': _encode(_log('a', '1')),
'ns/revs/phone': revisionOf(_encode(_log('a', '1'))),
});
await _tick(remote, PersistedSyncStateStore(persistence));
remote.reads.clear();
await _tick(remote, PersistedSyncStateStore(persistence));
expect(remote.reads, isNot(contains('ns/devices/phone/log.json')));
});
test('reports nothing remembered when absent', () async {
final loaded = await PersistedSyncStateStore(_FakePersistence()).load();
expect(loaded.pushedRev, isNull);
expect(loaded.peerRevs, isEmpty);
});
test('degrades to remembering nothing when empty', () async {
final loaded = await PersistedSyncStateStore(
_FakePersistence(text: ''),
).load();
expect(loaded.pushedRev, isNull);
});
test('degrades to remembering nothing when corrupt', () async {
// Costs one tick of extra traffic rather than failing the sync.
final loaded = await PersistedSyncStateStore(
_FakePersistence(text: 'not json{'),
).load();
expect(loaded.pushedRev, isNull);
});
test('degrades to remembering nothing when not an object', () async {
final loaded = await PersistedSyncStateStore(
_FakePersistence(text: '["a list"]'),
).load();
expect(loaded.pushedRev, isNull);
});
test('degrades to remembering nothing when the read throws', () async {
// An unreadable file must not fail the tick.
final loaded = await PersistedSyncStateStore(
_FakePersistence(readThrows: true),
).load();
expect(loaded.pushedRev, isNull);
});
});
}
/// A [LogPersistence] fake with injectable failure modes.
class _FakePersistence implements LogPersistence {
_FakePersistence({this.text, this.readThrows = false});
String? text;
final bool readThrows;
@override
Future<String?> read() async {
if (readThrows) throw const FormatException('unreadable');
return text;
}
@override
Future<void> write(String value) async => text = value;
} }

View File

@ -0,0 +1,175 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
Hlc _make(int wallTimeMs, {int counter = 0, String nodeId = 'node-a'}) =>
Hlc(wallTimeMs: wallTimeMs, counter: counter, nodeId: nodeId);
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
http.Response _response(int statusCode, [Object? jsonBody]) =>
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
class _PutCall {
_PutCall(this.path, this.body);
final String path;
final Map<String, dynamic> body;
}
/// Builds a [GitHubClient] backed by an in-memory router: GET
/// `.../contents/<key>` returns `contentResponses[key]` (404 if absent), the
/// bare repo-existence GET always succeeds, and every PUT is recorded into
/// [putCalls] and answered with 200.
({GitHubClient client, List<_PutCall> putCalls}) _client({
Map<String, http.Response> contentResponses = const {},
}) {
final putCalls = <_PutCall>[];
final mock = http_testing.MockClient((request) async {
final path = request.url.path;
if (!path.contains('/contents/')) {
return _response(200);
}
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
putCalls.add(
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
);
return _response(200);
}
return contentResponses[key] ?? _response(404);
});
final client = GitHubClient(
owner: 'kuhyx',
repo: 'crdt-sync-demo',
token: 'fake-token',
httpClient: mock,
);
return (client: client, putCalls: putCalls);
}
http.Response _directoryOf(List<String> deviceIds) =>
_response(200, deviceIds.map((id) => {'name': id, 'type': 'dir'}).toList());
http.Response _fileContaining(String text) =>
_response(200, {'content': base64.encode(utf8.encode(text))});
void main() {
group('syncLog skips unusable peer files', () {
test('skips a device with no pushed file yet', () async {
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, isEmpty);
expect(putCalls.single.body['content'], base64.encode(utf8.encode('{}')));
});
test('skips a device whose pushed file is corrupt', () async {
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
'devices/phone/log.json': _fileContaining('{not valid json'),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, isEmpty);
expect(putCalls, hasLength(1));
});
test('skips a device whose pushed JSON has the wrong shape', () async {
// Valid JSON that isn't a record map (e.g. from an incompatible
// writer) must be skipped like corrupt JSON, not crash the whole
// sync -- this is what the `on TypeError` catch in `syncLog` is
// for, not just JSON syntax errors caught by `FormatException`.
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
'devices/phone/log.json': _fileContaining('{"a": 5}'),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, isEmpty);
expect(putCalls, hasLength(1));
});
test('merges in a remote device\'s entries', () async {
final remoteLog = <String, Record>{
'b': Record(id: 'b', fields: {'text': ('from phone', _make(100))}),
};
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
'devices/phone/log.json': _fileContaining(_encode(remoteLog)),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, equals(remoteLog));
});
test('uses a custom filename and commit message', () async {
final (:client, :putCalls) = _client(
contentResponses: {'devices': _directoryOf([])},
);
await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
filename: 'notes.json',
commitMessage: 'custom message',
);
expect(putCalls.single.path, 'devices/pc/notes.json');
expect(putCalls.single.body['message'], 'custom message');
});
});
}

View File

@ -156,114 +156,5 @@ void main() {
expect(merged.keys, contains('stale')); expect(merged.keys, contains('stale'));
}); });
test('skips a device with no pushed file yet', () async {
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, isEmpty);
expect(putCalls.single.body['content'], base64.encode(utf8.encode('{}')));
});
test('skips a device whose pushed file is corrupt', () async {
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
'devices/phone/log.json': _fileContaining('{not valid json'),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, isEmpty);
expect(putCalls, hasLength(1));
});
test('skips a device whose pushed JSON has the wrong shape', () async {
// Valid JSON that isn't a record map (e.g. from an incompatible
// writer) must be skipped like corrupt JSON, not crash the whole
// sync -- this is what the `on TypeError` catch in `syncLog` is
// for, not just JSON syntax errors caught by `FormatException`.
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
'devices/phone/log.json': _fileContaining('{"a": 5}'),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, isEmpty);
expect(putCalls, hasLength(1));
});
test('merges in a remote device\'s entries', () async {
final remoteLog = <String, Record>{
'b': Record(id: 'b', fields: {'text': ('from phone', _make(100))}),
};
final (:client, :putCalls) = _client(
contentResponses: {
'devices': _directoryOf(['phone']),
'devices/phone/log.json': _fileContaining(_encode(remoteLog)),
},
);
final merged = await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
);
expect(merged, equals(remoteLog));
});
test('uses a custom filename and commit message', () async {
final (:client, :putCalls) = _client(
contentResponses: {'devices': _directoryOf([])},
);
await syncLog(
client: client,
deviceId: 'pc',
pathPrefix: 'devices',
localLog: {},
encode: _encode,
decode: _decode,
filename: 'notes.json',
commitMessage: 'custom message',
);
expect(putCalls.single.path, 'devices/pc/notes.json');
expect(putCalls.single.body['message'], 'custom message');
});
}); });
} }

View File

@ -0,0 +1,204 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Never actually waits: the poll loop's pacing is injected so a test that
/// exercises `slow_down` does not take 15 real seconds.
Future<void> noDelay(Duration _) async {}
GitHubDeviceAuth authWith(
MockClient client, {
String scope = 'repo',
}) => GitHubDeviceAuth(
clientId: 'cid',
scope: scope,
httpClient: client,
delay: noDelay,
);
void main() {
group('pollForToken', () {
const device = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
expiresIn: 60,
);
test('returns the token once the user authorizes', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
// First poll pending, second succeeds -- the normal path.
return http.Response(
calls == 1
? jsonEncode({'error': 'authorization_pending'})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
expect(calls, 2);
});
test('obeys slow_down by taking GitHub s new interval', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
return http.Response(
calls == 1
? jsonEncode({'error': 'slow_down', 'interval': 30})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
});
test('slow_down without an interval still backs off', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
return http.Response(
calls == 1
? jsonEncode({'error': 'slow_down'})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
});
test('throws on a terminal error', () async {
final auth = authWith(
MockClient(
(_) async => http.Response(
jsonEncode({
'error': 'access_denied',
'error_description': 'user said no',
}),
200,
),
),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'access_denied')
.having((e) => e.message, 'message', 'user said no'),
),
);
});
test('falls back to the code when no description is given', () async {
final auth = authWith(
MockClient(
(_) async => http.Response(jsonEncode({'error': 'expired'}), 200),
),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>().having((e) => e.message, 'msg', 'expired'),
),
);
});
test('throws on a response with neither a token nor an error', () async {
final auth = authWith(
MockClient((_) async => http.Response(jsonEncode({'x': 1}), 200)),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>().having((e) => e.code, 'code', 'unknown'),
),
);
});
test('retries a transient network failure instead of giving up', () async {
// GitHub can close the connection at the moment the user authorizes,
// and a phone can drop its network mid-poll. Losing an approved grant
// to a socket blip is the bug this retry exists to prevent.
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
if (calls == 1) {
throw http.ClientException('Connection closed before full header');
}
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
}),
);
expect(await auth.pollForToken(device), 'tok');
expect(calls, 2);
});
test('gives up once the device code has expired', () async {
const expired = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
// Already in the past, so the loop body never runs.
expiresIn: -1,
);
final auth = authWith(MockClient((_) async => http.Response('{}', 200)));
await expectLater(
auth.pollForToken(expired),
throwsA(
isA<DeviceAuthException>().having(
(e) => e.code,
'code',
'expired_token',
),
),
);
});
});
test('close() is safe to call', () {
authWith(MockClient((_) async => http.Response('{}', 200))).close();
});
test('constructs with a real http client and default scope', () {
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
expect(auth.scope, 'repo');
});
test('polls the overridden token endpoint', () async {
late Uri seen;
final auth = GitHubDeviceAuth(
clientId: 'cid',
tokenUrl: 'http://127.0.0.1:9/gh/auth/device/poll',
httpClient: MockClient((req) async {
seen = req.url;
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
}),
delay: noDelay,
);
await auth.pollForToken(
const DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
expiresIn: 60,
),
);
expect(seen.toString(), 'http://127.0.0.1:9/gh/auth/device/poll');
});
}

View File

@ -125,183 +125,4 @@ void main() {
); );
}); });
}); });
group('pollForToken', () {
const device = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
expiresIn: 60,
);
test('returns the token once the user authorizes', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
// First poll pending, second succeeds -- the normal path.
return http.Response(
calls == 1
? jsonEncode({'error': 'authorization_pending'})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
expect(calls, 2);
});
test('obeys slow_down by taking GitHub s new interval', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
return http.Response(
calls == 1
? jsonEncode({'error': 'slow_down', 'interval': 30})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
});
test('slow_down without an interval still backs off', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
return http.Response(
calls == 1
? jsonEncode({'error': 'slow_down'})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
});
test('throws on a terminal error', () async {
final auth = authWith(
MockClient(
(_) async => http.Response(
jsonEncode({
'error': 'access_denied',
'error_description': 'user said no',
}),
200,
),
),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'access_denied')
.having((e) => e.message, 'message', 'user said no'),
),
);
});
test('falls back to the code when no description is given', () async {
final auth = authWith(
MockClient(
(_) async => http.Response(jsonEncode({'error': 'expired'}), 200),
),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>().having((e) => e.message, 'msg', 'expired'),
),
);
});
test('throws on a response with neither a token nor an error', () async {
final auth = authWith(
MockClient((_) async => http.Response(jsonEncode({'x': 1}), 200)),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>().having((e) => e.code, 'code', 'unknown'),
),
);
});
test('retries a transient network failure instead of giving up', () async {
// GitHub can close the connection at the moment the user authorizes,
// and a phone can drop its network mid-poll. Losing an approved grant
// to a socket blip is the bug this retry exists to prevent.
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
if (calls == 1) {
throw http.ClientException('Connection closed before full header');
}
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
}),
);
expect(await auth.pollForToken(device), 'tok');
expect(calls, 2);
});
test('gives up once the device code has expired', () async {
const expired = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
// Already in the past, so the loop body never runs.
expiresIn: -1,
);
final auth = authWith(MockClient((_) async => http.Response('{}', 200)));
await expectLater(
auth.pollForToken(expired),
throwsA(
isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'expired_token'),
),
);
});
});
test('close() is safe to call', () {
authWith(MockClient((_) async => http.Response('{}', 200))).close();
});
test('constructs with a real http client and default scope', () {
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
expect(auth.scope, 'repo');
});
test('polls the overridden token endpoint', () async {
late Uri seen;
final auth = GitHubDeviceAuth(
clientId: 'cid',
tokenUrl: 'http://127.0.0.1:9/gh/auth/device/poll',
httpClient: MockClient((req) async {
seen = req.url;
return http.Response(jsonEncode({'access_token': 'tok'}), 200);
}),
delay: noDelay,
);
await auth.pollForToken(
const DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
expiresIn: 60,
),
);
expect(seen.toString(), 'http://127.0.0.1:9/gh/auth/device/poll');
});
} }

View File

@ -0,0 +1,143 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
FirebaseRestClient _fakeClient() => FirebaseRestClient(
databaseUrl: 'https://example.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'test-key',
store: InMemoryCredentialStore(),
),
);
Widget _wrap(Widget child) => MaterialApp(home: child);
void main() {
group('Backup section', () {
testWidgets('omitted entirely when backup is null', (tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Backup'), findsNothing);
});
testWidgets('export and import call the injected callbacks', (
tester,
) async {
var exported = false;
var imported = false;
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
backup: BackupSlot(
label: 'notes',
export: () async => exported = true,
import: () async => imported = true,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Backup'), findsOneWidget);
await tester.tap(find.text('Export notes'));
await tester.pumpAndSettle();
expect(exported, isTrue);
expect(find.text('Exported notes.'), findsOneWidget);
await tester.tap(find.text('Import notes'));
await tester.pumpAndSettle();
expect(imported, isTrue);
expect(find.text('Imported notes.'), findsOneWidget);
});
testWidgets('export failure reports the error', (tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
backup: BackupSlot(
label: 'notes',
export: () async => throw Exception('disk full'),
import: () async {},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Export notes'));
await tester.pumpAndSettle();
expect(find.textContaining('Export failed'), findsOneWidget);
});
testWidgets('import failure reports the error', (tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
backup: BackupSlot(
label: 'notes',
export: () async {},
import: () async => throw Exception('corrupt file'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import notes'));
await tester.pumpAndSettle();
expect(find.textContaining('Import failed'), findsOneWidget);
});
});
testWidgets('shows a loading indicator until initial load completes', (
tester,
) async {
final completer = Completer<FirebaseAccount?>();
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () => completer.future,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
),
),
);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete(null);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
});
}

View File

@ -0,0 +1,145 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
FirebaseRestClient _fakeClient() => FirebaseRestClient(
databaseUrl: 'https://example.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'test-key',
store: InMemoryCredentialStore(),
),
);
Widget _wrap(Widget child) => MaterialApp(home: child);
void main() {
group('Firebase section Google sign-in', () {
testWidgets('Google sign-in cancelled shows the cancellation message', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => null,
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.text('Google sign-in was cancelled.'), findsOneWidget);
});
testWidgets(
'Google sign-in that does not persist a session reports the retry '
'message',
(tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => _fakeClient(),
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(
find.textContaining('did not save the session'),
findsOneWidget,
);
},
);
testWidgets('Google sign-in wrong-account error surfaces the message', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async =>
throw FirebaseAuthError('wrong uid'),
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.text('wrong uid'), findsOneWidget);
});
testWidgets('Google sign-in generic failure surfaces the error text', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => throw StateError('boom'),
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.textContaining('Google sign-in failed'), findsOneWidget);
});
testWidgets(
'hides the Google button when googleAvailable is true but no '
'googleFirebaseFactory is wired -- a visible button that can never '
'succeed must not render',
(tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign in with Google'), findsNothing);
},
);
});
}

View File

@ -203,255 +203,5 @@ void main() {
expect(find.text('Connected to Firebase.'), findsOneWidget); expect(find.text('Connected to Firebase.'), findsOneWidget);
}, },
); );
testWidgets('Google sign-in cancelled shows the cancellation message', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => null,
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.text('Google sign-in was cancelled.'), findsOneWidget);
});
testWidgets(
'Google sign-in that does not persist a session reports the retry '
'message',
(tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => _fakeClient(),
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(
find.textContaining('did not save the session'),
findsOneWidget,
);
},
);
testWidgets('Google sign-in wrong-account error surfaces the message', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async =>
throw FirebaseAuthError('wrong uid'),
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.text('wrong uid'), findsOneWidget);
});
testWidgets('Google sign-in generic failure surfaces the error text', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => throw StateError('boom'),
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.textContaining('Google sign-in failed'), findsOneWidget);
});
testWidgets(
'hides the Google button when googleAvailable is true but no '
'googleFirebaseFactory is wired -- a visible button that can never '
'succeed must not render',
(tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleAvailable: true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign in with Google'), findsNothing);
},
);
});
group('Backup section', () {
testWidgets('omitted entirely when backup is null', (tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Backup'), findsNothing);
});
testWidgets('export and import call the injected callbacks', (
tester,
) async {
var exported = false;
var imported = false;
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
backup: BackupSlot(
label: 'notes',
export: () async => exported = true,
import: () async => imported = true,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Backup'), findsOneWidget);
await tester.tap(find.text('Export notes'));
await tester.pumpAndSettle();
expect(exported, isTrue);
expect(find.text('Exported notes.'), findsOneWidget);
await tester.tap(find.text('Import notes'));
await tester.pumpAndSettle();
expect(imported, isTrue);
expect(find.text('Imported notes.'), findsOneWidget);
});
testWidgets('export failure reports the error', (tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
backup: BackupSlot(
label: 'notes',
export: () async => throw Exception('disk full'),
import: () async {},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Export notes'));
await tester.pumpAndSettle();
expect(find.textContaining('Export failed'), findsOneWidget);
});
testWidgets('import failure reports the error', (tester) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
backup: BackupSlot(
label: 'notes',
export: () async {},
import: () async => throw Exception('corrupt file'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import notes'));
await tester.pumpAndSettle();
expect(find.textContaining('Import failed'), findsOneWidget);
});
});
testWidgets('shows a loading indicator until initial load completes', (
tester,
) async {
final completer = Completer<FirebaseAccount?>();
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () => completer.future,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
),
),
);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete(null);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
}); });
} }