import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:kouyu_english/core/app_state.dart'; import 'package:kouyu_english/core/models.dart'; import 'package:kouyu_english/core/sync/sync_coordinator.dart'; import 'package:kouyu_english/core/sync/sync_merger.dart'; import 'package:kouyu_english/core/sync/sync_models.dart'; import 'package:kouyu_english/core/sync/sync_service.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({}); }); group('Sync Models Serialization', () { test('SyncConfig toJson and fromJson round-trip', () { final config = SyncConfig( serverUrl: 'https://sync.example.com', token: 'test_token_123', username: 'alice', userId: 'u-1', lastSyncTime: DateTime.parse('2026-09-16T08:00:00.000Z'), autoSyncEnabled: true, ); final json = config.toJson(); final recovered = SyncConfig.fromJson(json); expect(recovered.serverUrl, 'https://sync.example.com'); expect(recovered.token, 'test_token_123'); expect(recovered.username, 'alice'); expect(recovered.userId, 'u-1'); expect(recovered.isLoggedIn, isTrue); expect(recovered.lastSyncTime?.toIso8601String(), '2026-09-16T08:00:00.000Z'); expect(recovered.autoSyncEnabled, isTrue); }); test('SyncPushRequest and SyncPullResponse parsing', () { final pullJson = { 'code': 0, 'message': 'success', 'data': { 'server_time': '2026-09-16T10:00:00Z', 'progress': { 'active_lesson_id': 'a0-02', 'completed_lesson_ids': ['a0-01', 'a0-02'], 'completed_segment_ids': ['a0-01-s1', 'a0-02-s1'], 'active_step': 'listening', 'streak_days': 2, 'updated_at': '2026-09-16T10:00:00Z', }, 'mastery_updates': [ { 'item_id': 'A0-P01', 'checkpoint': 3, 'status': 'use', 'due_at': '2026-09-17T10:00:00Z', 'successful_reviews': 3, 'attempts': 3, 'payload': {'label': 'I need coffee'}, 'updated_at': '2026-09-16T10:00:00Z', } ], 'profile': { 'onboarding_complete': true, 'goal': 'workplace', 'placement': 'A0', 'daily_minutes': 20, 'show_chinese_hints': true, 'updated_at': '2026-09-16T10:00:00Z', }, } }; final response = SyncPullResponse.fromJson(pullJson); expect(response.serverTime, '2026-09-16T10:00:00Z'); expect(response.progress?.completedLessonIds, ['a0-01', 'a0-02']); expect(response.masteryUpdates.length, 1); expect(response.masteryUpdates.first.itemId, 'A0-P01'); expect(response.masteryUpdates.first.checkpoint, 3); expect(response.profile?.goal, 'workplace'); }); }); group('SyncMerger Logic', () { test('buildPushRequest extracts current AppState data', () { final state = AppState(); state.completedLessonIds.add('a0-01'); state.completedLessons = 1; state.mastery['A0-P01'] = const MasteryItem( id: 'A0-P01', label: 'I am Shen', status: MasteryStatus.recall, checkpoint: 2, evidence: [EvidenceKind.independentSuccess], ); final req = SyncMerger.buildPushRequest(state, deviceName: 'MacBook'); expect(req.deviceName, 'MacBook'); expect(req.progress?.completedLessonIds, contains('a0-01')); expect(req.masteryUpdates.any((m) => m.itemId == 'A0-P01'), isTrue); final item = req.masteryUpdates.firstWhere((m) => m.itemId == 'A0-P01'); expect(item.checkpoint, 2); expect(item.status, 'recall'); }); test('applyPullResponse merges lessons by union and upgrades mastery by max checkpoint', () { final state = AppState(); state.completedLessonIds.add('a0-01'); state.completedLessons = 1; state.mastery['A0-P01'] = const MasteryItem( id: 'A0-P01', label: 'I am Shen', status: MasteryStatus.recognize, checkpoint: 1, evidence: [], ); final remotePull = SyncPullResponse( serverTime: '2026-09-16T10:00:00Z', progress: const SyncProgressPayload( activeLessonId: 'a0-03', completedLessonIds: ['a0-01', 'a0-02'], completedSegmentIds: ['a0-01-s1', 'a0-02-s1'], updatedAt: '2026-09-16T10:00:00Z', ), masteryUpdates: [ const SyncMasteryItemPayload( itemId: 'A0-P01', checkpoint: 3, status: 'use', dueAt: '2026-09-20T10:00:00Z', payload: {'label': 'I am Shen'}, updatedAt: '2026-09-16T10:00:00Z', ), const SyncMasteryItemPayload( itemId: 'A0-P02', checkpoint: 1, status: 'recognize', dueAt: '2026-09-18T10:00:00Z', payload: {'label': 'Thank you'}, updatedAt: '2026-09-16T10:00:00Z', ), ], profile: const SyncProfilePayload( onboardingComplete: true, goal: 'dailyLife', updatedAt: '2026-09-16T10:00:00Z', ), ); final changed = SyncMerger.applyPullResponse(state, remotePull); expect(changed, isTrue); // Lesson union expect(state.completedLessonIds, containsAll(['a0-01', 'a0-02'])); expect(state.completedLessons, 2); // Mastery max checkpoint upgrade expect(state.mastery['A0-P01']?.checkpoint, 3); expect(state.mastery['A0-P01']?.status, MasteryStatus.use); // New item added from remote expect(state.mastery['A0-P02']?.checkpoint, 1); expect(state.mastery['A0-P02']?.label, 'Thank you'); }); }); group('SyncService HTTP operations', () { test('testConnection returns true on 200 health check', () async { final mockClient = MockClient((request) async { if (request.url.path == '/api/v1/health') { return http.Response(jsonEncode({'status': 'ok'}), 200); } return http.Response('Not Found', 404); }); final service = SyncService(client: mockClient); final ok = await service.testConnection('http://127.0.0.1:8080'); expect(ok, isTrue); }); test('register and login return auth tokens', () async { final mockClient = MockClient((request) async { if (request.url.path == '/api/v1/auth/register' || request.url.path == '/api/v1/auth/login') { return http.Response( jsonEncode({ 'code': 0, 'message': 'success', 'data': { 'user_id': 'usr-888', 'username': 'shen', 'token': 'jwt_mock_token_abc', 'expires_in': 604800, } }), 200, ); } return http.Response('Not Found', 404); }); final service = SyncService(client: mockClient); final regRes = await service.register( serverUrl: 'http://127.0.0.1:8080', username: 'shen', password: 'password123', ); expect(regRes.userId, 'usr-888'); expect(regRes.token, 'jwt_mock_token_abc'); final logRes = await service.login( serverUrl: 'http://127.0.0.1:8080', username: 'shen', password: 'password123', ); expect(logRes.userId, 'usr-888'); expect(logRes.token, 'jwt_mock_token_abc'); }); test('pull and push send and receive data correctly', () async { final mockClient = MockClient((request) async { if (request.url.path == '/api/v1/sync/pull') { expect(request.headers['Authorization'], 'Bearer mock_token'); return http.Response( jsonEncode({ 'code': 0, 'message': 'success', 'data': { 'server_time': '2026-09-16T12:00:00Z', 'progress': { 'active_lesson_id': 'a0-01', 'completed_lesson_ids': ['a0-01'], 'completed_segment_ids': ['a0-01-s1'], 'active_step': 'speaking', 'streak_days': 1, 'updated_at': '2026-09-16T12:00:00Z', }, 'mastery_updates': [], 'profile': null, } }), 200, ); } else if (request.url.path == '/api/v1/sync/push') { expect(request.headers['Authorization'], 'Bearer mock_token'); return http.Response( jsonEncode({ 'code': 0, 'message': 'success', 'data': {'server_time': '2026-09-16T12:05:00Z'} }), 200, ); } return http.Response('Not Found', 404); }); final service = SyncService(client: mockClient); final pullResp = await service.pull( serverUrl: 'http://127.0.0.1:8080', token: 'mock_token', ); expect(pullResp.progress?.completedLessonIds, ['a0-01']); final sTime = await service.push( serverUrl: 'http://127.0.0.1:8080', token: 'mock_token', request: const SyncPushRequest(clientTime: '2026-09-16T12:04:00Z'), ); expect(sTime, '2026-09-16T12:05:00Z'); }); }); group('SyncCoordinator Integration', () { test('login, syncNow and logout lifecycle', () async { final mockClient = MockClient((request) async { if (request.url.path == '/api/v1/auth/login') { return http.Response( jsonEncode({ 'code': 0, 'message': 'success', 'data': { 'user_id': 'u100', 'username': 'tester', 'token': 'auth_token_999', 'expires_in': 604800, } }), 200, ); } else if (request.url.path == '/api/v1/sync/pull') { return http.Response( jsonEncode({ 'code': 0, 'message': 'success', 'data': { 'server_time': '2026-09-16T15:00:00Z', 'progress': { 'active_lesson_id': 'a0-01', 'completed_lesson_ids': ['a0-01'], 'completed_segment_ids': ['a0-01-s1'], 'active_step': 'reading', 'streak_days': 1, 'updated_at': '2026-09-16T15:00:00Z', }, 'mastery_updates': [], 'profile': null, } }), 200, ); } else if (request.url.path == '/api/v1/sync/push') { return http.Response( jsonEncode({ 'code': 0, 'message': 'success', 'data': {'server_time': '2026-09-16T15:00:01Z'} }), 200, ); } return http.Response('Not Found', 404); }); final service = SyncService(client: mockClient); final coordinator = SyncCoordinator.createForTesting(service: service); await coordinator.init(); expect(coordinator.isLoggedIn, isFalse); final loginSuccess = await coordinator.login( serverUrl: 'http://127.0.0.1:8080', username: 'tester', password: 'password123', ); expect(loginSuccess, isTrue); expect(coordinator.isLoggedIn, isTrue); expect(coordinator.username, 'tester'); final state = AppState(); final syncSuccess = await coordinator.syncNow(state); expect(syncSuccess, isTrue); expect(coordinator.state, SyncState.success); expect(coordinator.lastSyncTime, isNotNull); expect(state.completedLessonIds, contains('a0-01')); await coordinator.logout(); expect(coordinator.isLoggedIn, isFalse); expect(coordinator.config.token, isNull); }); }); }