merge(main): API de synchronisation incrémentale LWW (ticket #50)
Fusionne feature/server-50-sync-api — dart analyze clean, dart test 15/15 vert. SQL d'upsert LWW atomique relu manuellement et jugé correct ; pas de test de bout en bout contre un vrai PostgreSQL faute d'accès Docker dans ce sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#48"
|
issueRef: "#48"
|
||||||
version: 1
|
version: 4
|
||||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
updatedAt: 1784411990980
|
updatedAt: 1784448980586
|
||||||
---
|
---
|
||||||
|
Logique d'auth (register/login/logout/authenticate) et middleware Bearer testés avec repositories fake en mémoire (10/10 tests verts), `dart analyze` clean. Non vérifié en conditions réelles : sandbox Main sans accès Docker → endpoints HTTP register/login/logout jamais exercés de bout en bout contre un vrai PostgreSQL. À faire avant mise en prod : une fois #52 livré, `docker compose up -d`, puis tester manuellement `POST /auth/register`, `/auth/login`, `/auth/logout` via curl contre le serveur réel.
|
||||||
|
|
||||||
|
Écart assumé vs description initiale : le ticket mentionnait `POST /auth/refresh` et `GET /me`, mais la mémoire d'architecture `gametime-server-architecture-sync-sharing` (validée par Architect) a simplifié en un modèle à token opaque unique avec expiration (pas de refresh token séparé). `GET /me` n'a pas été livré non plus — le middleware expose déjà l'utilisateur authentifié en interne pour #50/#51, mais aucun endpoint public ne l'ex48pose. À réévaluer si un client a besoin d'un `GET /me` ou d'un refresh silencieux plus tard (ticket séparé si nécessaire).
|
||||||
@ -2,15 +2,15 @@
|
|||||||
id: "6df36ec4-94cd-438b-a2ee-10c022f6848a"
|
id: "6df36ec4-94cd-438b-a2ee-10c022f6848a"
|
||||||
number: 48
|
number: 48
|
||||||
title: "[Server] Auth comptes utilisateurs et tokens API"
|
title: "[Server] Auth comptes utilisateurs et tokens API"
|
||||||
status: "open"
|
status: "qa"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}]
|
links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}]
|
||||||
agentRefs: []
|
agentRefs: []
|
||||||
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
createdAt: 1784411990980
|
createdAt: 1784411990980
|
||||||
updatedAt: 1784411990980
|
updatedAt: 1784448980586
|
||||||
version: 1
|
version: 4
|
||||||
---
|
---
|
||||||
Implémenter le domaine serveur `UserAccount` et `AuthSession` : création de compte, connexion, hash de mot de passe Argon2id ou bcrypt, émission de tokens d'accès + refresh tokens, middleware d'auth API, révocation/logout. Stockage PostgreSQL. Endpoints attendus : `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /me`. Aucun écran client dans ce ticket.
|
Implémenter le domaine serveur `UserAccount` et `AuthSession` : création de compte, connexion, hash de mot de passe Argon2id ou bcrypt, émission de tokens d'accès + refresh tokens, middleware d'auth API, révocation/logout. Stockage PostgreSQL. Endpoints attendus : `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /me`. Aucun écran client dans ce ticket.
|
||||||
@ -547,11 +547,11 @@
|
|||||||
"issueRef": "#48",
|
"issueRef": "#48",
|
||||||
"path": "48",
|
"path": "48",
|
||||||
"title": "[Server] Auth comptes utilisateurs et tokens API",
|
"title": "[Server] Auth comptes utilisateurs et tokens API",
|
||||||
"status": "open",
|
"status": "qa",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784411990980
|
"updatedAt": 1784448980586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#49",
|
"issueRef": "#49",
|
||||||
|
|||||||
@ -84,12 +84,32 @@ Passwords are stored with PBKDF2-HMAC-SHA256 via `package:cryptography`, using a
|
|||||||
per-password random salt. API tokens are opaque random values; only a SHA-256
|
per-password random salt. API tokens are opaque random values; only a SHA-256
|
||||||
hash of the token is stored in PostgreSQL.
|
hash of the token is stored in PostgreSQL.
|
||||||
|
|
||||||
|
## Sync
|
||||||
|
|
||||||
|
Ticket #50 adds authenticated incremental sync endpoints using simple
|
||||||
|
last-write-wins conflict resolution based on `clientUpdatedAt`.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `POST /sync/push` with `Authorization: Bearer <token>`.
|
||||||
|
- `GET /sync/pull?since=<serverCursor>` with `Authorization: Bearer <token>`.
|
||||||
|
- `POST /sync/exchange` with `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
`serverCursor` is an ISO8601 UTC timestamp. For push, it is the greatest
|
||||||
|
`server_updated_at` currently known for the authenticated user after applying
|
||||||
|
the batch. For pull, it is the greatest `serverUpdatedAt` returned, or the
|
||||||
|
server clock if no resource is returned.
|
||||||
|
|
||||||
|
`POST /sync/exchange` applies push first, then returns the pull payload with
|
||||||
|
`pushResults` included so per-item validation errors remain visible to the
|
||||||
|
client.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
|
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
|
||||||
the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48
|
the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48
|
||||||
adds authentication only; sync endpoints and sharing behavior are still
|
adds authentication. Ticket #50 adds sync only; sharing behavior is still
|
||||||
implemented in later tickets.
|
implemented in a later ticket.
|
||||||
|
|
||||||
Upcoming tickets will fill the empty adapters and use cases:
|
Upcoming tickets will fill the empty adapters and use cases:
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:gametime_server/api/auth_api.dart';
|
import 'package:gametime_server/api/auth_api.dart';
|
||||||
import 'package:gametime_server/api/router.dart';
|
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/application/application.dart';
|
||||||
import 'package:gametime_server/infrastructure/postgres/postgres.dart';
|
import 'package:gametime_server/infrastructure/postgres/postgres.dart';
|
||||||
import 'package:gametime_server/infrastructure/security/security.dart';
|
import 'package:gametime_server/infrastructure/security/security.dart';
|
||||||
@ -15,6 +16,7 @@ Future<void> main(List<String> arguments) async {
|
|||||||
|
|
||||||
final users = PostgresUserRepository(connection);
|
final users = PostgresUserRepository(connection);
|
||||||
final sessions = PostgresAuthSessionRepository(connection);
|
final sessions = PostgresAuthSessionRepository(connection);
|
||||||
|
final resources = PostgresSyncedResourceRepository(connection);
|
||||||
final passwordHasher = Pbkdf2PasswordHasher();
|
final passwordHasher = Pbkdf2PasswordHasher();
|
||||||
final tokens = SecureOpaqueTokenService();
|
final tokens = SecureOpaqueTokenService();
|
||||||
const clock = SystemClock();
|
const clock = SystemClock();
|
||||||
@ -43,9 +45,21 @@ Future<void> main(List<String> arguments) async {
|
|||||||
logout: LogoutUseCase(sessions: sessions, tokens: tokens, clock: clock),
|
logout: LogoutUseCase(sessions: sessions, tokens: tokens, clock: clock),
|
||||||
authenticateRequest: authenticateRequest,
|
authenticateRequest: authenticateRequest,
|
||||||
);
|
);
|
||||||
|
final pushSync = PushSyncUseCase(
|
||||||
|
resources: resources,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
);
|
||||||
|
final pullSync = PullSyncUseCase(resources: resources, clock: clock);
|
||||||
|
final syncApi = SyncApi(
|
||||||
|
pushSync: pushSync,
|
||||||
|
pullSync: pullSync,
|
||||||
|
exchangeSync: ExchangeSyncUseCase(push: pushSync, pull: pullSync),
|
||||||
|
authenticateRequest: authenticateRequest,
|
||||||
|
);
|
||||||
|
|
||||||
final server = await shelf_io.serve(
|
final server = await shelf_io.serve(
|
||||||
buildApiHandler(authApi: authApi),
|
buildApiHandler(authApi: authApi, syncApi: syncApi),
|
||||||
InternetAddress.anyIPv4,
|
InternetAddress.anyIPv4,
|
||||||
port,
|
port,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import 'package:shelf/shelf.dart';
|
|||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
import 'auth_api.dart';
|
import 'auth_api.dart';
|
||||||
|
import 'sync_api.dart';
|
||||||
|
|
||||||
Handler buildApiHandler({AuthApi? authApi}) {
|
Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) {
|
||||||
final router = Router()
|
final router = Router()
|
||||||
..get('/health', (Request request) {
|
..get('/health', (Request request) {
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
@ -26,5 +27,27 @@ Handler buildApiHandler({AuthApi? authApi}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (syncApi != null) {
|
||||||
|
router
|
||||||
|
..post(
|
||||||
|
'/sync/push',
|
||||||
|
authenticationMiddleware(syncApi.authenticateRequest.execute)(
|
||||||
|
syncApi.push,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..get(
|
||||||
|
'/sync/pull',
|
||||||
|
authenticationMiddleware(syncApi.authenticateRequest.execute)(
|
||||||
|
syncApi.pull,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..post(
|
||||||
|
'/sync/exchange',
|
||||||
|
authenticationMiddleware(syncApi.authenticateRequest.execute)(
|
||||||
|
syncApi.exchange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
||||||
}
|
}
|
||||||
|
|||||||
216
server/lib/api/sync_api.dart
Normal file
216
server/lib/api/sync_api.dart
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
||||||
|
import '../application/application.dart';
|
||||||
|
import '../domain/domain.dart';
|
||||||
|
import 'auth_api.dart';
|
||||||
|
|
||||||
|
final class SyncApi {
|
||||||
|
const SyncApi({
|
||||||
|
required this.pushSync,
|
||||||
|
required this.pullSync,
|
||||||
|
required this.exchangeSync,
|
||||||
|
required this.authenticateRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PushSyncUseCase pushSync;
|
||||||
|
final PullSyncUseCase pullSync;
|
||||||
|
final ExchangeSyncUseCase exchangeSync;
|
||||||
|
final AuthenticateRequestUseCase authenticateRequest;
|
||||||
|
|
||||||
|
Future<Response> push(Request request) async {
|
||||||
|
final authenticated = authenticatedRequestFrom(request);
|
||||||
|
if (authenticated == null) {
|
||||||
|
return _errorResponse(401, 'Unauthorized.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final body = await _readJsonObject(request);
|
||||||
|
final result = await pushSync.execute(
|
||||||
|
ownerUserId: authenticated.user.id,
|
||||||
|
deviceId: _optionalString(body, 'deviceId'),
|
||||||
|
items: _pushItems(body['items']),
|
||||||
|
);
|
||||||
|
return _jsonResponse(200, _pushResultJson(result));
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
return _errorResponse(400, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> pull(Request request) async {
|
||||||
|
final authenticated = authenticatedRequestFrom(request);
|
||||||
|
if (authenticated == null) {
|
||||||
|
return _errorResponse(401, 'Unauthorized.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final since = _optionalDateTime(request.url.queryParameters['since']);
|
||||||
|
final result = await pullSync.execute(
|
||||||
|
ownerUserId: authenticated.user.id,
|
||||||
|
since: since,
|
||||||
|
);
|
||||||
|
return _jsonResponse(200, _pullResultJson(result));
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
return _errorResponse(400, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> exchange(Request request) async {
|
||||||
|
final authenticated = authenticatedRequestFrom(request);
|
||||||
|
if (authenticated == null) {
|
||||||
|
return _errorResponse(401, 'Unauthorized.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final body = await _readJsonObject(request);
|
||||||
|
final result = await exchangeSync.execute(
|
||||||
|
ownerUserId: authenticated.user.id,
|
||||||
|
deviceId: _optionalString(body, 'deviceId'),
|
||||||
|
items: _pushItems(body['items']),
|
||||||
|
since: _optionalDateTime(_optionalString(body, 'since')),
|
||||||
|
);
|
||||||
|
return _jsonResponse(200, {
|
||||||
|
'serverCursor': result.pull.serverCursor.toIso8601String(),
|
||||||
|
'pushResults': _pushResultsJson(result.push.results),
|
||||||
|
'items': _resourcesJson(result.pull.items),
|
||||||
|
});
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
return _errorResponse(400, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PushSyncItemInput> _pushItems(Object? rawItems) {
|
||||||
|
if (rawItems is! List) {
|
||||||
|
throw const FormatException('items must be a JSON array.');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final rawItem in rawItems)
|
||||||
|
if (rawItem is Map)
|
||||||
|
_pushItem(Map<String, Object?>.from(rawItem))
|
||||||
|
else
|
||||||
|
const PushSyncItemInput.invalid(
|
||||||
|
message: 'Sync item must be a JSON object.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
PushSyncItemInput _pushItem(Map<String, Object?> item) {
|
||||||
|
try {
|
||||||
|
final payload = item['payload'];
|
||||||
|
if (payload is! Map) {
|
||||||
|
return PushSyncItemInput.invalid(
|
||||||
|
resourceType: _stringOrNull(item['resourceType']),
|
||||||
|
clientId: _stringOrNull(item['clientId']),
|
||||||
|
message: 'payload must be a JSON object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PushSyncItemInput(
|
||||||
|
resourceType: _stringOrNull(item['resourceType']),
|
||||||
|
clientId: _stringOrNull(item['clientId']),
|
||||||
|
schemaVersion: item['schemaVersion'] is int
|
||||||
|
? item['schemaVersion'] as int
|
||||||
|
: null,
|
||||||
|
clientUpdatedAt: _optionalDateTime(
|
||||||
|
_stringOrNull(item['clientUpdatedAt']),
|
||||||
|
),
|
||||||
|
deletedAt: _optionalDateTime(_stringOrNull(item['deletedAt'])),
|
||||||
|
payloadJson: Map<String, Object?>.from(payload),
|
||||||
|
);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
return PushSyncItemInput.invalid(
|
||||||
|
resourceType: _stringOrNull(item['resourceType']),
|
||||||
|
clientId: _stringOrNull(item['clientId']),
|
||||||
|
message: error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _pushResultJson(PushSyncResult result) {
|
||||||
|
return {
|
||||||
|
'serverCursor': result.serverCursor.toIso8601String(),
|
||||||
|
'results': _pushResultsJson(result.results),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object?>> _pushResultsJson(List<PushSyncResultItem> results) {
|
||||||
|
return [
|
||||||
|
for (final item in results)
|
||||||
|
{
|
||||||
|
'resourceType': item.resourceType,
|
||||||
|
'clientId': item.clientId,
|
||||||
|
'serverId': item.serverId,
|
||||||
|
'status': item.status,
|
||||||
|
'serverUpdatedAt': item.serverUpdatedAt?.toIso8601String(),
|
||||||
|
if (item.message != null) 'message': item.message,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _pullResultJson(PullSyncResult result) {
|
||||||
|
return {
|
||||||
|
'serverCursor': result.serverCursor.toIso8601String(),
|
||||||
|
'items': _resourcesJson(result.items),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object?>> _resourcesJson(List<SyncedResource> resources) {
|
||||||
|
return [
|
||||||
|
for (final resource in resources)
|
||||||
|
{
|
||||||
|
'resourceType': resource.resourceType.wireName,
|
||||||
|
'clientId': resource.clientId,
|
||||||
|
'serverId': resource.serverId,
|
||||||
|
'schemaVersion': resource.schemaVersion,
|
||||||
|
'clientUpdatedAt': resource.clientUpdatedAt.toIso8601String(),
|
||||||
|
'serverUpdatedAt': resource.serverUpdatedAt.toIso8601String(),
|
||||||
|
'deletedAt': resource.deletedAt?.toIso8601String(),
|
||||||
|
'payload': resource.payloadJson,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> _readJsonObject(Request request) async {
|
||||||
|
final raw = await request.readAsString();
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, Object?>) {
|
||||||
|
throw const FormatException('Request body must be a JSON object.');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _optionalString(Map<String, Object?> body, String key) {
|
||||||
|
final value = body[key];
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is! String) {
|
||||||
|
throw FormatException('$key must be a string.');
|
||||||
|
}
|
||||||
|
final trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _stringOrNull(Object? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value is String ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _optionalDateTime(String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return DateTime.parse(value).toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _jsonResponse(int statusCode, Map<String, Object?> body) {
|
||||||
|
return Response(
|
||||||
|
statusCode,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _errorResponse(int statusCode, String message) {
|
||||||
|
return _jsonResponse(statusCode, {'error': message});
|
||||||
|
}
|
||||||
@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
|
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
|
||||||
|
|
||||||
Authentication use cases live here and depend only on repository/security
|
Authentication and sync use cases live here and depend only on repository /
|
||||||
ports. Concrete adapters are wired from `bin/server.dart`.
|
security ports. Concrete adapters are wired from `bin/server.dart`.
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export 'ports.dart';
|
export 'ports.dart';
|
||||||
|
export 'sync_use_cases.dart';
|
||||||
export 'use_cases.dart';
|
export 'use_cases.dart';
|
||||||
|
|||||||
@ -22,6 +22,24 @@ abstract interface class AuthSessionRepository {
|
|||||||
Future<void> revoke({required String sessionId, required DateTime revokedAt});
|
Future<void> revoke({required String sessionId, required DateTime revokedAt});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SyncWriteStatus { accepted, ignoredOlder }
|
||||||
|
|
||||||
|
final class SyncWriteResult {
|
||||||
|
const SyncWriteResult({required this.status, required this.resource});
|
||||||
|
|
||||||
|
final SyncWriteStatus status;
|
||||||
|
final SyncedResource resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class SyncedResourceRepository {
|
||||||
|
Future<SyncWriteResult> upsertWithLww(SyncedResource resource);
|
||||||
|
|
||||||
|
Future<List<SyncedResource>> findAllForUserSince({
|
||||||
|
required String ownerUserId,
|
||||||
|
DateTime? since,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
abstract interface class PasswordHasher {
|
abstract interface class PasswordHasher {
|
||||||
Future<String> hash(String password);
|
Future<String> hash(String password);
|
||||||
|
|
||||||
|
|||||||
237
server/lib/application/sync_use_cases.dart
Normal file
237
server/lib/application/sync_use_cases.dart
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import '../domain/domain.dart';
|
||||||
|
import 'ports.dart';
|
||||||
|
|
||||||
|
final class PushSyncItemInput {
|
||||||
|
const PushSyncItemInput({
|
||||||
|
required this.resourceType,
|
||||||
|
required this.clientId,
|
||||||
|
required this.schemaVersion,
|
||||||
|
required this.clientUpdatedAt,
|
||||||
|
required this.deletedAt,
|
||||||
|
required this.payloadJson,
|
||||||
|
this.validationError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PushSyncItemInput.invalid({
|
||||||
|
required String message,
|
||||||
|
this.resourceType,
|
||||||
|
this.clientId,
|
||||||
|
}) : schemaVersion = null,
|
||||||
|
clientUpdatedAt = null,
|
||||||
|
deletedAt = null,
|
||||||
|
payloadJson = null,
|
||||||
|
validationError = message;
|
||||||
|
|
||||||
|
final String? resourceType;
|
||||||
|
final String? clientId;
|
||||||
|
final int? schemaVersion;
|
||||||
|
final DateTime? clientUpdatedAt;
|
||||||
|
final DateTime? deletedAt;
|
||||||
|
final Map<String, Object?>? payloadJson;
|
||||||
|
final String? validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PushSyncResultItem {
|
||||||
|
const PushSyncResultItem({
|
||||||
|
required this.resourceType,
|
||||||
|
required this.clientId,
|
||||||
|
this.serverId,
|
||||||
|
required this.status,
|
||||||
|
this.serverUpdatedAt,
|
||||||
|
this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? resourceType;
|
||||||
|
final String? clientId;
|
||||||
|
final String? serverId;
|
||||||
|
final String status;
|
||||||
|
final DateTime? serverUpdatedAt;
|
||||||
|
final String? message;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PushSyncResult {
|
||||||
|
const PushSyncResult({required this.serverCursor, required this.results});
|
||||||
|
|
||||||
|
final DateTime serverCursor;
|
||||||
|
final List<PushSyncResultItem> results;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PullSyncResult {
|
||||||
|
const PullSyncResult({required this.serverCursor, required this.items});
|
||||||
|
|
||||||
|
final DateTime serverCursor;
|
||||||
|
final List<SyncedResource> items;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ExchangeSyncResult {
|
||||||
|
const ExchangeSyncResult({required this.push, required this.pull});
|
||||||
|
|
||||||
|
final PushSyncResult push;
|
||||||
|
final PullSyncResult pull;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PushSyncUseCase {
|
||||||
|
const PushSyncUseCase({
|
||||||
|
required this.resources,
|
||||||
|
required this.clock,
|
||||||
|
required this.ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SyncedResourceRepository resources;
|
||||||
|
final Clock clock;
|
||||||
|
final IdGenerator ids;
|
||||||
|
|
||||||
|
Future<PushSyncResult> execute({
|
||||||
|
required String ownerUserId,
|
||||||
|
required String? deviceId,
|
||||||
|
required List<PushSyncItemInput> items,
|
||||||
|
}) async {
|
||||||
|
final results = <PushSyncResultItem>[];
|
||||||
|
final normalizedDeviceId = _blankToNull(deviceId);
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
try {
|
||||||
|
final validationError = item.validationError;
|
||||||
|
if (validationError != null) {
|
||||||
|
throw ValidationException(validationError);
|
||||||
|
}
|
||||||
|
if (normalizedDeviceId == null) {
|
||||||
|
throw const ValidationException('deviceId must not be blank.');
|
||||||
|
}
|
||||||
|
final resource = SyncedResource(
|
||||||
|
serverId: ids.newId(),
|
||||||
|
ownerUserId: ownerUserId,
|
||||||
|
resourceType: SyncedResourceType.parse(
|
||||||
|
_required(item.resourceType, 'resourceType'),
|
||||||
|
),
|
||||||
|
clientId: _required(item.clientId, 'clientId'),
|
||||||
|
payloadJson: item.payloadJson ?? _missing('payload'),
|
||||||
|
schemaVersion: item.schemaVersion ?? _missing('schemaVersion'),
|
||||||
|
clientUpdatedAt: item.clientUpdatedAt ?? _missing('clientUpdatedAt'),
|
||||||
|
serverUpdatedAt: clock.now(),
|
||||||
|
deletedAt: item.deletedAt,
|
||||||
|
originDeviceId: normalizedDeviceId,
|
||||||
|
);
|
||||||
|
final written = await resources.upsertWithLww(resource);
|
||||||
|
results.add(
|
||||||
|
PushSyncResultItem(
|
||||||
|
resourceType: written.resource.resourceType.wireName,
|
||||||
|
clientId: written.resource.clientId,
|
||||||
|
serverId: written.resource.serverId,
|
||||||
|
status: written.status == SyncWriteStatus.accepted
|
||||||
|
? 'accepted'
|
||||||
|
: 'ignoredOlder',
|
||||||
|
serverUpdatedAt: written.resource.serverUpdatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on ValidationException catch (error) {
|
||||||
|
results.add(
|
||||||
|
PushSyncResultItem(
|
||||||
|
resourceType: item.resourceType,
|
||||||
|
clientId: item.clientId,
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
results.add(
|
||||||
|
PushSyncResultItem(
|
||||||
|
resourceType: item.resourceType,
|
||||||
|
clientId: item.clientId,
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PushSyncResult(
|
||||||
|
serverCursor: await _currentCursor(resources, ownerUserId, clock),
|
||||||
|
results: results,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PullSyncUseCase {
|
||||||
|
const PullSyncUseCase({required this.resources, required this.clock});
|
||||||
|
|
||||||
|
final SyncedResourceRepository resources;
|
||||||
|
final Clock clock;
|
||||||
|
|
||||||
|
Future<PullSyncResult> execute({
|
||||||
|
required String ownerUserId,
|
||||||
|
DateTime? since,
|
||||||
|
}) async {
|
||||||
|
final items = await resources.findAllForUserSince(
|
||||||
|
ownerUserId: ownerUserId,
|
||||||
|
since: since?.toUtc(),
|
||||||
|
);
|
||||||
|
return PullSyncResult(
|
||||||
|
serverCursor: _cursorFromItems(items, clock.now()),
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ExchangeSyncUseCase {
|
||||||
|
const ExchangeSyncUseCase({required this.push, required this.pull});
|
||||||
|
|
||||||
|
final PushSyncUseCase push;
|
||||||
|
final PullSyncUseCase pull;
|
||||||
|
|
||||||
|
Future<ExchangeSyncResult> execute({
|
||||||
|
required String ownerUserId,
|
||||||
|
required String? deviceId,
|
||||||
|
required List<PushSyncItemInput> items,
|
||||||
|
DateTime? since,
|
||||||
|
}) async {
|
||||||
|
final pushResult = await push.execute(
|
||||||
|
ownerUserId: ownerUserId,
|
||||||
|
deviceId: deviceId,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
|
final pullResult = await pull.execute(
|
||||||
|
ownerUserId: ownerUserId,
|
||||||
|
since: since,
|
||||||
|
);
|
||||||
|
return ExchangeSyncResult(push: pushResult, pull: pullResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime> _currentCursor(
|
||||||
|
SyncedResourceRepository resources,
|
||||||
|
String ownerUserId,
|
||||||
|
Clock clock,
|
||||||
|
) async {
|
||||||
|
final allItems = await resources.findAllForUserSince(
|
||||||
|
ownerUserId: ownerUserId,
|
||||||
|
);
|
||||||
|
return _cursorFromItems(allItems, clock.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _cursorFromItems(List<SyncedResource> items, DateTime fallback) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return fallback.toUtc();
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.map((item) => item.serverUpdatedAt)
|
||||||
|
.reduce((left, right) => left.isAfter(right) ? left : right)
|
||||||
|
.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _required(String? value, String label) {
|
||||||
|
final normalized = _blankToNull(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw ValidationException('$label must not be blank.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Never _missing(String label) {
|
||||||
|
throw ValidationException('$label is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _blankToNull(String? value) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
return trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||||
|
}
|
||||||
@ -5,4 +5,4 @@ Pure server domain entities and invariants.
|
|||||||
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
|
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
|
||||||
other infrastructure concerns.
|
other infrastructure concerns.
|
||||||
|
|
||||||
Current entities: `UserAccount` and `AuthSession`.
|
Current entities: `UserAccount`, `AuthSession` and `SyncedResource`.
|
||||||
|
|||||||
@ -88,6 +88,63 @@ final class AuthSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SyncedResourceType {
|
||||||
|
exercise('exercise'),
|
||||||
|
program('program'),
|
||||||
|
workoutTemplate('workoutTemplate'),
|
||||||
|
workoutHistory('workoutHistory'),
|
||||||
|
mediaAsset('mediaAsset');
|
||||||
|
|
||||||
|
const SyncedResourceType(this.wireName);
|
||||||
|
|
||||||
|
final String wireName;
|
||||||
|
|
||||||
|
static SyncedResourceType parse(String value) {
|
||||||
|
for (final type in values) {
|
||||||
|
if (type.wireName == value) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ValidationException('Unsupported resource type: $value.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SyncedResource {
|
||||||
|
SyncedResource({
|
||||||
|
required String serverId,
|
||||||
|
required String ownerUserId,
|
||||||
|
required this.resourceType,
|
||||||
|
required String clientId,
|
||||||
|
required Map<String, Object?> payloadJson,
|
||||||
|
required this.schemaVersion,
|
||||||
|
required DateTime clientUpdatedAt,
|
||||||
|
required DateTime serverUpdatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
this.originDeviceId,
|
||||||
|
}) : serverId = _nonBlank(serverId, 'Server resource id'),
|
||||||
|
ownerUserId = _nonBlank(ownerUserId, 'Owner user id'),
|
||||||
|
clientId = _nonBlank(clientId, 'Client resource id'),
|
||||||
|
payloadJson = Map.unmodifiable(payloadJson),
|
||||||
|
clientUpdatedAt = clientUpdatedAt.toUtc(),
|
||||||
|
serverUpdatedAt = serverUpdatedAt.toUtc(),
|
||||||
|
deletedAt = deletedAt?.toUtc() {
|
||||||
|
if (schemaVersion <= 0) {
|
||||||
|
throw const ValidationException('Schema version must be positive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final String serverId;
|
||||||
|
final String ownerUserId;
|
||||||
|
final SyncedResourceType resourceType;
|
||||||
|
final String clientId;
|
||||||
|
final Map<String, Object?> payloadJson;
|
||||||
|
final int schemaVersion;
|
||||||
|
final DateTime clientUpdatedAt;
|
||||||
|
final DateTime serverUpdatedAt;
|
||||||
|
final DateTime? deletedAt;
|
||||||
|
final String? originDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
String _nonBlank(String value, String label) {
|
String _nonBlank(String value, String label) {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
Connection and migration utilities for server-side PostgreSQL.
|
Connection and migration utilities for server-side PostgreSQL.
|
||||||
|
|
||||||
Repository adapters will be added by the sync/auth tickets once their ports are
|
Current adapters cover auth users/sessions and the generic `synced_resources`
|
||||||
defined.
|
sync table.
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export 'auth_repositories.dart';
|
export 'auth_repositories.dart';
|
||||||
export 'postgres_database.dart';
|
export 'postgres_database.dart';
|
||||||
|
export 'synced_resource_repository.dart';
|
||||||
|
|||||||
@ -0,0 +1,173 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
|
import '../../application/application.dart';
|
||||||
|
import '../../domain/domain.dart';
|
||||||
|
|
||||||
|
final class PostgresSyncedResourceRepository
|
||||||
|
implements SyncedResourceRepository {
|
||||||
|
const PostgresSyncedResourceRepository(this.connection);
|
||||||
|
|
||||||
|
final Connection connection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||||
|
final result = await connection.execute(
|
||||||
|
Sql.named('''
|
||||||
|
WITH upserted AS (
|
||||||
|
INSERT INTO synced_resources (
|
||||||
|
server_id, owner_user_id, resource_type, client_id, payload_json,
|
||||||
|
schema_version, client_updated_at, deleted_at, origin_device_id
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@server_id::uuid, @owner_user_id::uuid, @resource_type, @client_id,
|
||||||
|
@payload_json::jsonb, @schema_version, @client_updated_at,
|
||||||
|
@deleted_at, @origin_device_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_user_id, resource_type, client_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
payload_json = EXCLUDED.payload_json,
|
||||||
|
schema_version = EXCLUDED.schema_version,
|
||||||
|
client_updated_at = EXCLUDED.client_updated_at,
|
||||||
|
deleted_at = EXCLUDED.deleted_at,
|
||||||
|
origin_device_id = EXCLUDED.origin_device_id
|
||||||
|
WHERE synced_resources.client_updated_at < EXCLUDED.client_updated_at
|
||||||
|
RETURNING
|
||||||
|
server_id, owner_user_id, resource_type, client_id, payload_json,
|
||||||
|
schema_version, client_updated_at, server_updated_at, deleted_at,
|
||||||
|
origin_device_id, 'accepted'::text AS write_status
|
||||||
|
)
|
||||||
|
SELECT * FROM upserted
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
existing.server_id, existing.owner_user_id, existing.resource_type,
|
||||||
|
existing.client_id, existing.payload_json, existing.schema_version,
|
||||||
|
existing.client_updated_at, existing.server_updated_at,
|
||||||
|
existing.deleted_at, existing.origin_device_id,
|
||||||
|
'ignoredOlder'::text AS write_status
|
||||||
|
FROM synced_resources existing
|
||||||
|
WHERE existing.owner_user_id = @owner_user_id::uuid
|
||||||
|
AND existing.resource_type = @resource_type
|
||||||
|
AND existing.client_id = @client_id
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM upserted)
|
||||||
|
LIMIT 1
|
||||||
|
'''),
|
||||||
|
parameters: {
|
||||||
|
'server_id': resource.serverId,
|
||||||
|
'owner_user_id': resource.ownerUserId,
|
||||||
|
'resource_type': resource.resourceType.wireName,
|
||||||
|
'client_id': resource.clientId,
|
||||||
|
'payload_json': jsonEncode(resource.payloadJson),
|
||||||
|
'schema_version': resource.schemaVersion,
|
||||||
|
'client_updated_at': resource.clientUpdatedAt,
|
||||||
|
'deleted_at': resource.deletedAt,
|
||||||
|
'origin_device_id': resource.originDeviceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final row = result.single;
|
||||||
|
final values = row.toColumnMap() as Map<String, Object?>;
|
||||||
|
return SyncWriteResult(
|
||||||
|
status: values['write_status'] == 'accepted'
|
||||||
|
? SyncWriteStatus.accepted
|
||||||
|
: SyncWriteStatus.ignoredOlder,
|
||||||
|
resource: _resourceFromValues(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SyncedResource>> findAllForUserSince({
|
||||||
|
required String ownerUserId,
|
||||||
|
DateTime? since,
|
||||||
|
}) async {
|
||||||
|
final result = since == null
|
||||||
|
? await connection.execute(
|
||||||
|
Sql.named('''
|
||||||
|
SELECT server_id, owner_user_id, resource_type, client_id,
|
||||||
|
payload_json, schema_version, client_updated_at,
|
||||||
|
server_updated_at, deleted_at, origin_device_id
|
||||||
|
FROM synced_resources
|
||||||
|
WHERE owner_user_id = @owner_user_id::uuid
|
||||||
|
ORDER BY server_updated_at ASC, server_id ASC
|
||||||
|
'''),
|
||||||
|
parameters: {'owner_user_id': ownerUserId},
|
||||||
|
)
|
||||||
|
: await connection.execute(
|
||||||
|
Sql.named('''
|
||||||
|
SELECT server_id, owner_user_id, resource_type, client_id,
|
||||||
|
payload_json, schema_version, client_updated_at,
|
||||||
|
server_updated_at, deleted_at, origin_device_id
|
||||||
|
FROM synced_resources
|
||||||
|
WHERE owner_user_id = @owner_user_id::uuid
|
||||||
|
AND server_updated_at > @since
|
||||||
|
ORDER BY server_updated_at ASC, server_id ASC
|
||||||
|
'''),
|
||||||
|
parameters: {'owner_user_id': ownerUserId, 'since': since.toUtc()},
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
for (final row in result)
|
||||||
|
_resourceFromValues(row.toColumnMap() as Map<String, Object?>),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncedResource _resourceFromValues(Map<String, Object?> values) {
|
||||||
|
return SyncedResource(
|
||||||
|
serverId: _stringValue(values['server_id']),
|
||||||
|
ownerUserId: _stringValue(values['owner_user_id']),
|
||||||
|
resourceType: SyncedResourceType.parse(
|
||||||
|
_stringValue(values['resource_type']),
|
||||||
|
),
|
||||||
|
clientId: _stringValue(values['client_id']),
|
||||||
|
payloadJson: _payloadValue(values['payload_json']),
|
||||||
|
schemaVersion: values['schema_version'] as int,
|
||||||
|
clientUpdatedAt: _dateTimeValue(values['client_updated_at']),
|
||||||
|
serverUpdatedAt: _dateTimeValue(values['server_updated_at']),
|
||||||
|
deletedAt: _nullableDateTimeValue(values['deleted_at']),
|
||||||
|
originDeviceId: values['origin_device_id'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _payloadValue(Object? value) {
|
||||||
|
if (value is Map<String, Object?>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return Map<String, Object?>.from(value);
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
final decoded = jsonDecode(value);
|
||||||
|
if (decoded is Map<String, Object?>) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, Object?>.from(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw const FormatException('Expected JSON object payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringValue(Object? value) {
|
||||||
|
if (value == null) {
|
||||||
|
throw const FormatException('Expected non-null string value.');
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _dateTimeValue(Object? value) {
|
||||||
|
final result = _nullableDateTimeValue(value);
|
||||||
|
if (result == null) {
|
||||||
|
throw const FormatException('Expected non-null DateTime value.');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _nullableDateTimeValue(Object? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is DateTime) {
|
||||||
|
return value.toUtc();
|
||||||
|
}
|
||||||
|
return DateTime.parse(value.toString()).toUtc();
|
||||||
|
}
|
||||||
195
server/test/sync_api_test.dart
Normal file
195
server/test/sync_api_test.dart
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
items.add(resource);
|
||||||
|
return SyncWriteResult(
|
||||||
|
status: SyncWriteStatus.accepted,
|
||||||
|
resource: resource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SyncedResource>> findAllForUserSince({
|
||||||
|
required String ownerUserId,
|
||||||
|
DateTime? since,
|
||||||
|
}) async {
|
||||||
|
return items
|
||||||
|
.where((item) => item.ownerUserId == ownerUserId)
|
||||||
|
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
273
server/test/sync_use_cases_test.dart
Normal file
273
server/test/sync_use_cases_test.dart
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import 'package:gametime_server/application/application.dart';
|
||||||
|
import 'package:gametime_server/domain/domain.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _FakeSyncedResourceRepository resources;
|
||||||
|
late _FakeClock clock;
|
||||||
|
late _FakeIds ids;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
||||||
|
ids = _FakeIds();
|
||||||
|
resources = _FakeSyncedResourceRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push inserts, updates newer items and ignores older or equal items',
|
||||||
|
() async {
|
||||||
|
resources.put(
|
||||||
|
_resource(
|
||||||
|
serverId: 'existing-server-id',
|
||||||
|
resourceType: SyncedResourceType.exercise,
|
||||||
|
clientId: 'exercise-1',
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
||||||
|
payloadJson: {'name': 'old'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final useCase = PushSyncUseCase(
|
||||||
|
resources: resources,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await useCase.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: 'program',
|
||||||
|
clientId: 'program-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 9),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {'name': 'program'},
|
||||||
|
),
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: 'exercise',
|
||||||
|
clientId: 'exercise-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {'name': 'same'},
|
||||||
|
),
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: 'exercise',
|
||||||
|
clientId: 'exercise-1',
|
||||||
|
schemaVersion: 2,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, 1),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {'name': 'new'},
|
||||||
|
),
|
||||||
|
const PushSyncItemInput(
|
||||||
|
resourceType: 'unknown',
|
||||||
|
clientId: 'bad-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.results.map((item) => item.status), [
|
||||||
|
'accepted',
|
||||||
|
'ignoredOlder',
|
||||||
|
'accepted',
|
||||||
|
'error',
|
||||||
|
]);
|
||||||
|
final updated = resources.get(
|
||||||
|
'user-1',
|
||||||
|
SyncedResourceType.exercise,
|
||||||
|
'exercise-1',
|
||||||
|
);
|
||||||
|
expect(updated?.serverId, 'existing-server-id');
|
||||||
|
expect(updated?.payloadJson, {'name': 'new'});
|
||||||
|
expect(updated?.schemaVersion, 2);
|
||||||
|
expect(updated?.originDeviceId, 'device-1');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'pull returns resources newer than cursor including soft deletes',
|
||||||
|
() async {
|
||||||
|
resources
|
||||||
|
..put(
|
||||||
|
_resource(
|
||||||
|
serverId: 'resource-1',
|
||||||
|
resourceType: SyncedResourceType.exercise,
|
||||||
|
clientId: 'exercise-1',
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..put(
|
||||||
|
_resource(
|
||||||
|
serverId: 'resource-2',
|
||||||
|
resourceType: SyncedResourceType.program,
|
||||||
|
clientId: 'program-1',
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
||||||
|
deletedAt: DateTime.utc(2026, 7, 19, 10, 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final useCase = PullSyncUseCase(resources: resources, clock: clock);
|
||||||
|
|
||||||
|
final result = await useCase.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
since: DateTime.utc(2026, 7, 19, 10, 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items.map((item) => item.clientId), ['program-1']);
|
||||||
|
expect(result.items.single.deletedAt, DateTime.utc(2026, 7, 19, 10, 30));
|
||||||
|
expect(result.serverCursor, DateTime.utc(2026, 7, 19, 11));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('exchange pushes first then pulls the updated state', () async {
|
||||||
|
final push = PushSyncUseCase(resources: resources, clock: clock, ids: ids);
|
||||||
|
final pull = PullSyncUseCase(resources: resources, clock: clock);
|
||||||
|
final useCase = ExchangeSyncUseCase(push: push, pull: pull);
|
||||||
|
|
||||||
|
final result = await useCase.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: 'mediaAsset',
|
||||||
|
clientId: 'media-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {'localUri': 'file://image.jpg'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.push.results.single.status, 'accepted');
|
||||||
|
expect(result.pull.items.single.clientId, 'media-1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncedResource _resource({
|
||||||
|
required String serverId,
|
||||||
|
required SyncedResourceType resourceType,
|
||||||
|
required String clientId,
|
||||||
|
DateTime? clientUpdatedAt,
|
||||||
|
DateTime? serverUpdatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
Map<String, Object?> payloadJson = const {},
|
||||||
|
}) {
|
||||||
|
return SyncedResource(
|
||||||
|
serverId: serverId,
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
resourceType: resourceType,
|
||||||
|
clientId: clientId,
|
||||||
|
payloadJson: payloadJson,
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: clientUpdatedAt ?? DateTime.utc(2026, 7, 19, 9),
|
||||||
|
serverUpdatedAt: serverUpdatedAt ?? DateTime.utc(2026, 7, 19, 10),
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
originDeviceId: 'device-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||||
|
final _items = <String, SyncedResource>{};
|
||||||
|
|
||||||
|
void put(SyncedResource resource) {
|
||||||
|
_items[_key(
|
||||||
|
resource.ownerUserId,
|
||||||
|
resource.resourceType,
|
||||||
|
resource.clientId,
|
||||||
|
)] =
|
||||||
|
resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncedResource? get(
|
||||||
|
String ownerUserId,
|
||||||
|
SyncedResourceType resourceType,
|
||||||
|
String clientId,
|
||||||
|
) {
|
||||||
|
return _items[_key(ownerUserId, resourceType, clientId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||||
|
final key = _key(
|
||||||
|
resource.ownerUserId,
|
||||||
|
resource.resourceType,
|
||||||
|
resource.clientId,
|
||||||
|
);
|
||||||
|
final existing = _items[key];
|
||||||
|
if (existing != null &&
|
||||||
|
!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
|
||||||
|
return SyncWriteResult(
|
||||||
|
status: SyncWriteStatus.ignoredOlder,
|
||||||
|
resource: existing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final written = existing == null
|
||||||
|
? resource
|
||||||
|
: 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[key] = written;
|
||||||
|
return SyncWriteResult(status: SyncWriteStatus.accepted, resource: written);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SyncedResource>> findAllForUserSince({
|
||||||
|
required String ownerUserId,
|
||||||
|
DateTime? since,
|
||||||
|
}) async {
|
||||||
|
final result =
|
||||||
|
_items.values
|
||||||
|
.where((item) => item.ownerUserId == ownerUserId)
|
||||||
|
.where(
|
||||||
|
(item) =>
|
||||||
|
since == null || item.serverUpdatedAt.isAfter(since.toUtc()),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.serverUpdatedAt.compareTo(right.serverUpdatedAt),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _key(
|
||||||
|
String ownerUserId,
|
||||||
|
SyncedResourceType resourceType,
|
||||||
|
String clientId,
|
||||||
|
) {
|
||||||
|
return '$ownerUserId:${resourceType.wireName}:$clientId';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'id-$_next';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user