diet-guard/app/test/services/github_client_test.dart
Krzysztof kuhy Rudnicki a82047502f Add Flutter half of cross-device sync (Milestone 3)
Ports github_client.dart and sync_settings.dart from ~/todo (PAT-paste
instead of OAuth device flow), and writes a new (non-CRDT) sync_merge.dart
and sync_service.dart matching diet_guard's Python _sync_merge.py/_sync.py
algorithm exactly. Adds a settings screen for the PAT plus manual "Sync
now", and wires lifecycle-triggered auto-sync (launch + resumed/paused)
into the main logging screen, silent on failure per plan decision 4.

Also adds Linux desktop platform scaffolding so this and future UI changes
can be visually verified without a connected phone.

Verified end-to-end against the real kuhyx/diet-guard-sync GitHub API on a
Linux desktop build: Test connection and Sync now both round-trip to GitHub
and surface real auth errors correctly via SnackBar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RH2BHCKbDTiYJUMG3rb9nq
2026-06-22 22:42:27 +02:00

133 lines
4.1 KiB
Dart

import 'dart:convert';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
GitHubClient client(MockClient mock) =>
GitHubClient(owner: 'o', repo: 'r', token: 't', httpClient: mock);
test('listDirectory returns only files and ignores subdirectories', () async {
final mock = MockClient((req) async {
expect(req.headers['Authorization'], contains('t'));
return http.Response(
jsonEncode([
{'type': 'file', 'name': 'a.json', 'path': 'd/a.json', 'sha': 's1'},
{'type': 'dir', 'name': 'sub', 'path': 'd/sub', 'sha': 's2'},
]),
200,
);
});
final files = await client(mock).listDirectory('d');
expect(files, hasLength(1));
expect(files.single.name, 'a.json');
expect(files.single.sha, 's1');
});
test(
'listDirectory returns empty on 404 (directory not created yet)',
() async {
final files = await client(
MockClient((_) async => http.Response('', 404)),
).listDirectory('missing');
expect(files, isEmpty);
},
);
test('listEntryNames returns both file and directory names', () async {
final mock = MockClient(
(_) async => http.Response(
jsonEncode([
{'type': 'dir', 'name': 'pc', 'path': 'devices/pc', 'sha': 's1'},
{'type': 'dir', 'name': 'phone', 'path': 'devices/phone', 'sha': 's2'},
]),
200,
),
);
expect(await client(mock).listEntryNames('devices'), ['pc', 'phone']);
});
test('listEntryNames returns empty on 404', () async {
final files = await client(
MockClient((_) async => http.Response('', 404)),
).listEntryNames('missing');
expect(files, isEmpty);
});
test('getFileText base64-decodes content; null on 404', () async {
final encoded = base64.encode(utf8.encode('hello world'));
final ok = MockClient(
(_) async => http.Response(jsonEncode({'content': encoded}), 200),
);
expect(await client(ok).getFileText('f'), 'hello world');
final missing = MockClient((_) async => http.Response('', 404));
expect(await client(missing).getFileText('f'), isNull);
});
test(
'putFileText omits sha when creating, includes it when updating',
() async {
String? sentBody;
final mock = MockClient((req) async {
sentBody = req.body;
return http.Response('{}', 201);
});
await client(mock).putFileText('f', 'data');
expect(jsonDecode(sentBody!).containsKey('sha'), isFalse);
await client(mock).putFileText('f', 'data', sha: 'abc');
expect(jsonDecode(sentBody!)['sha'], 'abc');
},
);
test('deleteFile sends the sha', () async {
String? body;
final mock = MockClient((req) async {
body = req.body;
return http.Response('{}', 200);
});
await client(mock).deleteFile('f', 'sha123');
expect(jsonDecode(body!)['sha'], 'sha123');
});
test('canAccessRepo reflects the status code', () async {
expect(
await client(
MockClient((_) async => http.Response('{}', 200)),
).canAccessRepo(),
isTrue,
);
expect(
await client(
MockClient((_) async => http.Response('', 403)),
).canAccessRepo(),
isFalse,
);
});
test('throws GitHubApiException on a non-2xx that is not 404', () async {
final mock = MockClient((_) async => http.Response('boom', 500));
expect(
() => client(mock).getFileText('f'),
throwsA(isA<GitHubApiException>()),
);
});
test('GitHubApiException.toString includes status and message', () {
expect(
GitHubApiException(500, 'boom').toString(),
'GitHubApiException(500): boom',
);
});
test('creates a default http client when none is injected', () {
// No httpClient → the constructor builds a real http.Client; just make
// sure that branch runs and the client closes cleanly (no request made).
final c = GitHubClient(owner: 'o', repo: 'r', token: 't');
addTearDown(c.close);
});
}