Fixe .gitignore pour exclure .build-home/ et .pub-cache-local/ des environnements locaux de build (bruit non versionne). Documente le checklist d'integration serveur et met a jour le README en consequence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
763 lines
24 KiB
Dart
763 lines
24 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:gametime_server/api/router.dart';
|
|
import 'package:gametime_server/api/sync_api.dart';
|
|
import 'package:gametime_server/application/application.dart';
|
|
import 'package:gametime_server/domain/domain.dart';
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import 'fixtures/sync_fixtures.dart';
|
|
|
|
void main() {
|
|
test('sync routes require bearer authentication', () async {
|
|
final handler = buildApiHandler(syncApi: _syncApi());
|
|
|
|
final response = await handler(
|
|
Request('GET', Uri.parse('http://localhost/sync/pull')),
|
|
);
|
|
|
|
expect(response.statusCode, 401);
|
|
});
|
|
|
|
test('push parses item errors without blocking valid items', () async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-1',
|
|
'schemaVersion': 1,
|
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
|
'deletedAt': null,
|
|
'payload': {'name': 'Squat'},
|
|
},
|
|
{
|
|
'resourceType': 'bad',
|
|
'clientId': 'bad-1',
|
|
'schemaVersion': 1,
|
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
|
'payload': {},
|
|
},
|
|
'malformed',
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
final results = body['results'] as List;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect(results.map((item) => (item as Map)['status']), [
|
|
'accepted',
|
|
'error',
|
|
'error',
|
|
]);
|
|
expect(repository.items.single.clientId, 'exercise-1');
|
|
});
|
|
|
|
test(
|
|
'push and pull round-trip versioned exercise fixtures strictly',
|
|
() async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final fixtures = loadExerciseFixtures();
|
|
|
|
final pushResponse = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
for (var index = 0; index < fixtures.length; index += 1)
|
|
{
|
|
'resourceType': fixtures[index].resourceType,
|
|
'clientId': 'exercise-${fixtures[index].name}',
|
|
'schemaVersion': fixtures[index].schemaVersion,
|
|
'clientUpdatedAt':
|
|
'2026-07-19T10:${index.toString().padLeft(2, '0')}:00Z',
|
|
'deletedAt': null,
|
|
'payload': fixtures[index].payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
|
|
|
final pullResponse = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
|
|
final pulledItems = pullBody['items'] as List;
|
|
|
|
expect(pushResponse.statusCode, 200);
|
|
expect(
|
|
(pushBody['results'] as List).map((item) => (item as Map)['status']),
|
|
everyElement('accepted'),
|
|
);
|
|
expect(pullResponse.statusCode, 200);
|
|
expect(pulledItems, hasLength(fixtures.length));
|
|
for (final fixture in fixtures) {
|
|
final pulled = pulledItems.cast<Map>().singleWhere(
|
|
(item) => item['clientId'] == 'exercise-${fixture.name}',
|
|
);
|
|
expect(pulled['resourceType'], 'exercise');
|
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
|
expect(pulled['payload'], fixture.payload);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('push and pull round-trip workoutTemplate fixture strictly', () async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final fixture = loadWorkoutTemplateFixture('minimal');
|
|
|
|
final pushResponse = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
{
|
|
'resourceType': fixture.resourceType,
|
|
'clientId': 'template-minimal',
|
|
'schemaVersion': fixture.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:30:00Z',
|
|
'payload': fixture.payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
|
|
|
final pullResponse = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
|
|
final pulled = (pullBody['items'] as List).cast<Map>().single;
|
|
|
|
expect(pushResponse.statusCode, 200);
|
|
expect(((pushBody['results'] as List).single as Map)['status'], 'accepted');
|
|
expect(pullResponse.statusCode, 200);
|
|
expect(pulled['resourceType'], 'workoutTemplate');
|
|
expect(pulled['clientId'], 'template-minimal');
|
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
|
expect(pulled['payload'], fixture.payload);
|
|
});
|
|
|
|
test(
|
|
'push and pull round-trip all reusable minimal resource fixtures',
|
|
() async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final fixtures = loadMinimalResourceFixtures();
|
|
|
|
final pushResponse = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
for (var index = 0; index < fixtures.length; index += 1)
|
|
{
|
|
'resourceType': fixtures[index].resourceType,
|
|
'clientId': '${fixtures[index].resourceType}-minimal',
|
|
'schemaVersion': fixtures[index].schemaVersion,
|
|
'clientUpdatedAt':
|
|
'2026-07-19T11:${index.toString().padLeft(2, '0')}:00Z',
|
|
'payload': fixtures[index].payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
|
|
|
final pullResponse = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
final pulledItems =
|
|
(jsonDecode(await pullResponse.readAsString()) as Map)['items']
|
|
as List;
|
|
|
|
expect(pushResponse.statusCode, 200);
|
|
expect(
|
|
(pushBody['results'] as List).map((item) => (item as Map)['status']),
|
|
everyElement('accepted'),
|
|
);
|
|
expect(pullResponse.statusCode, 200);
|
|
for (final fixture in fixtures) {
|
|
final pulled = pulledItems.cast<Map>().singleWhere(
|
|
(item) => item['clientId'] == '${fixture.resourceType}-minimal',
|
|
);
|
|
expect(pulled['resourceType'], fixture.resourceType);
|
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
|
expect(pulled['payload'], fixture.payload);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'push and pull preserve exercise fixture batches across versions',
|
|
() async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final fixtures = [
|
|
loadExerciseFixture('minimal'),
|
|
loadExerciseFixture('minimal', version: 'v2'),
|
|
];
|
|
|
|
final pushResponse = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
for (var index = 0; index < fixtures.length; index += 1)
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-versioned-$index',
|
|
'schemaVersion': fixtures[index].schemaVersion,
|
|
'clientUpdatedAt':
|
|
'2026-07-19T12:${index.toString().padLeft(2, '0')}:00Z',
|
|
'payload': fixtures[index].payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
|
|
|
final pullResponse = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
final pulledItems =
|
|
(jsonDecode(await pullResponse.readAsString()) as Map)['items']
|
|
as List;
|
|
|
|
expect(pushResponse.statusCode, 200);
|
|
expect(
|
|
(pushBody['results'] as List).map((item) => (item as Map)['status']),
|
|
everyElement('accepted'),
|
|
);
|
|
expect(pullResponse.statusCode, 200);
|
|
for (var index = 0; index < fixtures.length; index += 1) {
|
|
final pulled = pulledItems.cast<Map>().singleWhere(
|
|
(item) => item['clientId'] == 'exercise-versioned-$index',
|
|
);
|
|
expect(pulled['schemaVersion'], fixtures[index].schemaVersion);
|
|
expect(pulled['payload'], fixtures[index].payload);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('push delete stores deletedAt and pull returns the tombstone', () async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final fixture = loadWorkoutTemplateFixture('minimal');
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
{
|
|
'resourceType': 'workoutTemplate',
|
|
'clientId': 'template-delete',
|
|
'schemaVersion': fixture.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:30:00Z',
|
|
'deletedAt': null,
|
|
'payload': fixture.payload,
|
|
},
|
|
{
|
|
'resourceType': 'workoutTemplate',
|
|
'clientId': 'template-delete',
|
|
'schemaVersion': fixture.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:31:00Z',
|
|
'deletedAt': '2026-07-19T10:31:00Z',
|
|
'payload': fixture.payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
final results = body['results'] as List;
|
|
|
|
final pullResponse = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
|
|
final pulled = (pullBody['items'] as List).cast<Map>().single;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect(results.map((item) => (item as Map)['status']), [
|
|
'accepted',
|
|
'accepted',
|
|
]);
|
|
expect(pullResponse.statusCode, 200);
|
|
expect(pulled['resourceType'], 'workoutTemplate');
|
|
expect(pulled['clientId'], 'template-delete');
|
|
expect(pulled['deletedAt'], '2026-07-19T10:31:00.000Z');
|
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
|
expect(pulled['payload'], fixture.payload);
|
|
});
|
|
|
|
test('push applies LWW accepted, older ignored and equal ignored', () async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final original = loadExerciseFixture('with_score_chrono');
|
|
final stale = loadExerciseFixture('minimal');
|
|
final newer = loadExerciseFixture('full_combo');
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'items': [
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-lww',
|
|
'schemaVersion': original.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
|
'payload': original.payload,
|
|
},
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-lww',
|
|
'schemaVersion': stale.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T09:59:59Z',
|
|
'payload': stale.payload,
|
|
},
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-lww',
|
|
'schemaVersion': stale.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
|
'payload': stale.payload,
|
|
},
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-lww',
|
|
'schemaVersion': newer.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:00:01Z',
|
|
'payload': newer.payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
final results = body['results'] as List;
|
|
final stored = repository.items.single;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect(results.map((item) => (item as Map)['status']), [
|
|
'accepted',
|
|
'ignoredOlder',
|
|
'ignoredOlder',
|
|
'accepted',
|
|
]);
|
|
expect(stored.schemaVersion, newer.schemaVersion);
|
|
expect(stored.payloadJson, newer.payload);
|
|
});
|
|
|
|
test('pull returns synced items and filters them with since query', () async {
|
|
final repository = _FakeSyncedResourceRepository()
|
|
..items.addAll([
|
|
SyncedResource(
|
|
serverId: 'resource-1',
|
|
ownerUserId: 'user-1',
|
|
resourceType: SyncedResourceType.exercise,
|
|
clientId: 'exercise-1',
|
|
payloadJson: {'name': 'Squat'},
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
|
),
|
|
SyncedResource(
|
|
serverId: 'resource-2',
|
|
ownerUserId: 'user-1',
|
|
resourceType: SyncedResourceType.program,
|
|
clientId: 'program-1',
|
|
payloadJson: {'name': 'Programme A'},
|
|
schemaVersion: 2,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 19, 12, 30),
|
|
),
|
|
]);
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull?since=2026-07-19T12:00:00Z'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
final items = body['items'] as List;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect(body['serverCursor'], '2026-07-19T12:30:00.000Z');
|
|
expect(items, hasLength(1));
|
|
expect((items.single as Map)['clientId'], 'program-1');
|
|
expect((items.single as Map)['resourceType'], 'program');
|
|
});
|
|
|
|
test('pull returns 400 for invalid since query parameter', () async {
|
|
final handler = buildApiHandler(syncApi: _syncApi());
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'GET',
|
|
Uri.parse('http://localhost/sync/pull?since=not-a-date'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
),
|
|
);
|
|
|
|
expect(response.statusCode, 400);
|
|
expect(jsonDecode(await response.readAsString()), {
|
|
'error': 'Invalid date format',
|
|
});
|
|
});
|
|
|
|
test('push returns 400 when request body is not a json object', () async {
|
|
final handler = buildApiHandler(syncApi: _syncApi());
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode(['not-an-object']),
|
|
),
|
|
);
|
|
|
|
expect(response.statusCode, 400);
|
|
expect(jsonDecode(await response.readAsString()), {
|
|
'error': 'Request body must be a JSON object.',
|
|
});
|
|
});
|
|
|
|
test(
|
|
'push rejects blank device id per item without storing payloads',
|
|
() async {
|
|
final repository = _FakeSyncedResourceRepository();
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
final fixture = loadExerciseFixture('minimal');
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/push'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': ' ',
|
|
'items': [
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-blank-device',
|
|
'schemaVersion': fixture.schemaVersion,
|
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
|
'payload': fixture.payload,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
final results = body['results'] as List;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect((results.single as Map)['status'], 'error');
|
|
expect((results.single as Map)['message'], 'deviceId must not be blank.');
|
|
expect(repository.items, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('exchange returns push results followed by pulled items', () async {
|
|
final repository = _FakeSyncedResourceRepository()
|
|
..items.add(
|
|
SyncedResource(
|
|
serverId: 'existing-1',
|
|
ownerUserId: 'user-1',
|
|
resourceType: SyncedResourceType.workoutHistory,
|
|
clientId: 'history-1',
|
|
payloadJson: {'score': 12},
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 8),
|
|
serverUpdatedAt: DateTime.utc(2026, 7, 19, 9),
|
|
),
|
|
);
|
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/exchange'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({
|
|
'deviceId': 'device-1',
|
|
'since': '2026-07-19T08:30:00Z',
|
|
'items': [
|
|
{
|
|
'resourceType': 'exercise',
|
|
'clientId': 'exercise-2',
|
|
'schemaVersion': 1,
|
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
|
'payload': {'name': 'Lunge'},
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
final pushResults = body['pushResults'] as List;
|
|
final items = body['items'] as List;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect(pushResults, hasLength(1));
|
|
expect((pushResults.single as Map)['status'], 'accepted');
|
|
expect(items, hasLength(2));
|
|
expect(
|
|
items.map((item) => (item as Map)['clientId']),
|
|
containsAll(['history-1', 'exercise-2']),
|
|
);
|
|
});
|
|
|
|
test('exchange returns 400 when items is not a json array', () async {
|
|
final handler = buildApiHandler(syncApi: _syncApi());
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/sync/exchange'),
|
|
headers: {'authorization': 'Bearer valid-token'},
|
|
body: jsonEncode({'deviceId': 'device-1', 'items': 'not-a-list'}),
|
|
),
|
|
);
|
|
|
|
expect(response.statusCode, 400);
|
|
expect(jsonDecode(await response.readAsString()), {
|
|
'error': 'items must be a JSON array.',
|
|
});
|
|
});
|
|
}
|
|
|
|
SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {
|
|
final repository = resources ?? _FakeSyncedResourceRepository();
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
|
final ids = _FakeIds();
|
|
final user = UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hash',
|
|
createdAt: clock.now(),
|
|
updatedAt: clock.now(),
|
|
);
|
|
final session = AuthSession(
|
|
id: 'session-1',
|
|
userId: user.id,
|
|
tokenHash: 'token-hash:valid-token',
|
|
issuedAt: clock.now(),
|
|
expiresAt: clock.now().add(const Duration(days: 1)),
|
|
);
|
|
final users = _FakeUserRepository(user);
|
|
final sessions = _FakeAuthSessionRepository(session);
|
|
final tokens = _FakeTokenService();
|
|
final authenticate = AuthenticateRequestUseCase(
|
|
users: users,
|
|
sessions: sessions,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
);
|
|
final push = PushSyncUseCase(resources: repository, clock: clock, ids: ids);
|
|
final pull = PullSyncUseCase(resources: repository, clock: clock);
|
|
return SyncApi(
|
|
pushSync: push,
|
|
pullSync: pull,
|
|
exchangeSync: ExchangeSyncUseCase(push: push, pull: pull),
|
|
authenticateRequest: authenticate,
|
|
);
|
|
}
|
|
|
|
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
|
final items = <SyncedResource>[];
|
|
|
|
@override
|
|
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
|
final existingIndex = items.indexWhere(
|
|
(item) =>
|
|
item.ownerUserId == resource.ownerUserId &&
|
|
item.resourceType == resource.resourceType &&
|
|
item.clientId == resource.clientId,
|
|
);
|
|
if (existingIndex != -1) {
|
|
final existing = items[existingIndex];
|
|
if (!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
|
|
return SyncWriteResult(
|
|
status: SyncWriteStatus.ignoredOlder,
|
|
resource: existing,
|
|
);
|
|
}
|
|
final written = SyncedResource(
|
|
serverId: existing.serverId,
|
|
ownerUserId: existing.ownerUserId,
|
|
resourceType: existing.resourceType,
|
|
clientId: existing.clientId,
|
|
payloadJson: resource.payloadJson,
|
|
schemaVersion: resource.schemaVersion,
|
|
clientUpdatedAt: resource.clientUpdatedAt,
|
|
serverUpdatedAt: resource.serverUpdatedAt,
|
|
deletedAt: resource.deletedAt,
|
|
originDeviceId: resource.originDeviceId,
|
|
);
|
|
items[existingIndex] = written;
|
|
return SyncWriteResult(
|
|
status: SyncWriteStatus.accepted,
|
|
resource: written,
|
|
);
|
|
}
|
|
items.add(resource);
|
|
return SyncWriteResult(
|
|
status: SyncWriteStatus.accepted,
|
|
resource: resource,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<SyncedResource>> findAllForUserSince({
|
|
required String ownerUserId,
|
|
DateTime? since,
|
|
}) async {
|
|
final result = items
|
|
.where((item) => item.ownerUserId == ownerUserId)
|
|
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
|
.toList();
|
|
result.sort(
|
|
(left, right) => left.serverUpdatedAt.compareTo(right.serverUpdatedAt),
|
|
);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
final class _FakeUserRepository implements UserRepository {
|
|
const _FakeUserRepository(this.user);
|
|
|
|
final UserAccount user;
|
|
|
|
@override
|
|
Future<UserAccount?> findByEmail(String email) async => user;
|
|
|
|
@override
|
|
Future<UserAccount?> findById(String id) async => id == user.id ? user : null;
|
|
|
|
@override
|
|
Future<void> insert(UserAccount user) async {}
|
|
|
|
@override
|
|
Future<void> updatePasswordHash({
|
|
required String userId,
|
|
required String passwordHash,
|
|
required DateTime updatedAt,
|
|
}) async {}
|
|
}
|
|
|
|
final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
|
const _FakeAuthSessionRepository(this.session);
|
|
|
|
final AuthSession session;
|
|
|
|
@override
|
|
Future<AuthSession?> findByTokenHash(String tokenHash) async {
|
|
return tokenHash == session.tokenHash ? session : null;
|
|
}
|
|
|
|
@override
|
|
Future<void> insert(AuthSession session) async {}
|
|
|
|
@override
|
|
Future<void> revoke({
|
|
required String sessionId,
|
|
required DateTime revokedAt,
|
|
}) async {}
|
|
}
|
|
|
|
final class _FakeTokenService implements OpaqueTokenService {
|
|
@override
|
|
String generateToken() => 'valid-token';
|
|
|
|
@override
|
|
String hashToken(String token) => 'token-hash:$token';
|
|
}
|
|
|
|
final class _FakeClock implements Clock {
|
|
_FakeClock(this.value);
|
|
|
|
DateTime value;
|
|
|
|
@override
|
|
DateTime now() => value;
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'server-id-$_next';
|
|
}
|
|
}
|