feat(server): tests API, contrats OpenAPI et vérification d'intégration (ticket #53)

Ajoute la spécification openapi.yaml documentant les 12 endpoints
réels du serveur (health, auth, sync, shares), les tests API auth
(test/auth_api_test.dart couvrant register/login/logout) et la
checklist de vérification d'intégration (docs/integration-checklist.md).
dart analyze clean, dart test 29/29 vert. Dernier ticket du chantier
serveur (#47 à #53), tous terminés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 15:56:29 +02:00
parent 00c83921c9
commit c1dce3cae7
4 changed files with 993 additions and 0 deletions

View File

@ -125,6 +125,15 @@ without failing the whole request. Accepting a share creates a new
`409` for revoked or already answered shares, while missing shares or recipients
return `404`.
## API Contract
The current HTTP contract is documented in [`openapi.yaml`](openapi.yaml). It
covers health, authentication, sync and targeted sharing endpoints implemented
by this server package.
To inspect it, paste the file into <https://editor.swagger.io/> or open it with
a local OpenAPI-compatible viewer.
## Docker Deployment
Ticket #52 adds Docker packaging for a headless Linux deployment. TLS is not

View File

@ -0,0 +1,104 @@
# GameTime Server Integration Checklist
This checklist covers the checks that require a real Docker daemon, PostgreSQL
instance, or Gitea registry. They are intentionally not part of the sandbox test
suite.
## Docker Compose
- Copy `server/.env.example` to `server/.env`.
- Replace `POSTGRES_PASSWORD` and `DATABASE_PASSWORD` with a real secret.
- Set `API_BIND_ADDRESS` to the Docker host interface reachable by the external
reverse proxy.
- Set `API_PORT` to the host port consumed by the reverse proxy.
- Run:
```bash
cd server
docker compose config
docker compose up -d --build
docker compose ps
docker compose logs api
```
- Verify that the `api` service waits for the PostgreSQL healthcheck.
- Verify that migrations run automatically before the server starts.
- Verify that TLS is terminated by the external reverse proxy, not by the API
container.
## PostgreSQL Migrations
- Start a disposable PostgreSQL database or reuse the Compose database.
- Run the real migration integration test:
```bash
cd server
TEST_DATABASE_URL=postgres://gametime:change-me@localhost:5432/gametime dart test
```
- Confirm the test sees these tables:
- `users`
- `auth_sessions`
- `synced_resources`
- `shares`
- `share_recipients`
## Manual API Smoke Tests
Run the following checks against the real server URL. Use HTTP directly only on
the private LAN path between the reverse proxy and Docker host; external access
should be HTTPS through the reverse proxy.
```bash
curl http://localhost:8080/health
```
- Register a user with `POST /auth/register`.
- Login with `POST /auth/login` and store the returned bearer token.
- Call a protected endpoint without a token and confirm `401`.
- Logout with `POST /auth/logout` and confirm the token can no longer access
protected endpoints.
- Push a valid resource with `POST /sync/push`.
- Pull it with `GET /sync/pull`.
- Push an older version of the same resource and confirm `ignoredOlder`.
- Use `POST /sync/exchange` and confirm push results and pulled items are both
present.
- Create a second user, then create a share with `POST /shares`.
- Confirm unknown recipient emails are returned in `unresolvedEmails`.
- List shares with `GET /shares/inbox` as the recipient.
- Accept a share with `POST /shares/{id}/accept` and confirm a copied resource
appears in `GET /sync/pull` for the recipient.
- Decline a pending share with `POST /shares/{id}/decline`.
- Revoke a sent share with `POST /shares/{id}/revoke`.
- Confirm accepting a revoked or already answered share returns `409`.
## LWW Concurrency
- Run two concurrent `POST /sync/push` requests for the same
`(owner_user_id, resource_type, client_id)` with different `clientUpdatedAt`
values.
- Confirm the row with the strictly newer `clientUpdatedAt` wins.
- Confirm an equal timestamp is ignored by the later request.
- Inspect `server_updated_at` to confirm the update trigger advances it on
accepted updates.
## Gitea Container Registry
- Obtain the real Gitea registry host, owner/namespace, username and token.
- Run:
```bash
cd server
GITEA_REGISTRY=gitea.example.com \
GITEA_OWNER=my-org \
GITEA_IMAGE_NAME=gametime-server \
GITEA_IMAGE_TAG=latest \
GITEA_USERNAME=my-user \
GITEA_TOKEN='replace-with-token' \
./scripts/push-gitea-image.sh
```
- Confirm the image exists in the Gitea Container Registry.
- Pull the pushed image on the deployment host.
- Deploy the pushed image with the same environment variables as the local
Compose build.

591
server/openapi.yaml Normal file
View File

@ -0,0 +1,591 @@
openapi: 3.0.3
info:
title: GameTime Server API
version: 0.1.0
description: Headless API for GameTime authentication, incremental sync and targeted sharing.
servers:
- url: http://localhost:8080
description: Local Docker or development server
tags:
- name: Health
- name: Auth
- name: Sync
- name: Shares
paths:
/health:
get:
tags: [Health]
summary: Healthcheck
responses:
'200':
description: Server is reachable.
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
/auth/register:
post:
tags: [Auth]
summary: Register a user account
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'201':
description: User created.
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterResponse'
'400':
$ref: '#/components/responses/BadRequest'
'409':
description: Email already taken.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/login:
post:
tags: [Auth]
summary: Login and create an API token session
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Login accepted. The clear token is returned once.
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/auth/logout:
post:
tags: [Auth]
summary: Revoke the current API token session
security:
- bearerAuth: []
responses:
'204':
description: Session revoked.
'401':
$ref: '#/components/responses/Unauthorized'
/sync/push:
post:
tags: [Sync]
summary: Push client-side resource mutations
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SyncPushRequest'
responses:
'200':
description: Batch processed. Item-level validation errors are returned in results.
content:
application/json:
schema:
$ref: '#/components/schemas/SyncPushResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/sync/pull:
get:
tags: [Sync]
summary: Pull resources updated after a server cursor
security:
- bearerAuth: []
parameters:
- name: since
in: query
required: false
schema:
type: string
format: date-time
description: ISO8601 UTC server cursor. If omitted, returns all resources.
responses:
'200':
description: Resources for the authenticated user.
content:
application/json:
schema:
$ref: '#/components/schemas/SyncPullResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/sync/exchange:
post:
tags: [Sync]
summary: Push then pull in one request
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SyncExchangeRequest'
responses:
'200':
description: Pull response after applying the push batch.
content:
application/json:
schema:
$ref: '#/components/schemas/SyncExchangeResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/shares:
post:
tags: [Shares]
summary: Create a targeted share
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateShareRequest'
responses:
'201':
description: Share created. Unknown recipient emails are reported but do not fail the request.
content:
application/json:
schema:
$ref: '#/components/schemas/CreateShareResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/shares/inbox:
get:
tags: [Shares]
summary: List shares received by the authenticated user
security:
- bearerAuth: []
responses:
'200':
description: Inbox sorted from newest to oldest.
content:
application/json:
schema:
$ref: '#/components/schemas/ShareInboxResponse'
'401':
$ref: '#/components/responses/Unauthorized'
/shares/{id}/accept:
post:
tags: [Shares]
summary: Accept a received share
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ShareId'
responses:
'200':
description: Share accepted and copied into synced resources.
content:
application/json:
schema:
$ref: '#/components/schemas/AcceptShareResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
/shares/{id}/decline:
post:
tags: [Shares]
summary: Decline a received share
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ShareId'
responses:
'204':
description: Share declined.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
/shares/{id}/revoke:
post:
tags: [Shares]
summary: Revoke a share sent by the authenticated user
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ShareId'
responses:
'204':
description: Share revoked.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: opaque
parameters:
ShareId:
name: id
in: path
required: true
schema:
type: string
description: Share identifier.
responses:
BadRequest:
description: Invalid JSON body, invalid parameter, or validation failure.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Unauthorized:
description: Missing, malformed, expired, revoked or unknown bearer token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
NotFound:
description: Resource not found or not accessible by the authenticated user.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Conflict:
description: Current resource state rejects the requested transition.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
schemas:
ErrorResponse:
type: object
required: [error]
properties:
error:
type: string
HealthResponse:
type: object
required: [status]
properties:
status:
type: string
enum: [ok]
RegisterRequest:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
displayName:
type: string
nullable: true
RegisterResponse:
type: object
required: [userId, email]
properties:
userId:
type: string
email:
type: string
format: email
LoginRequest:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
deviceLabel:
type: string
nullable: true
LoginResponse:
type: object
required: [token, expiresAt]
properties:
token:
type: string
expiresAt:
type: string
format: date-time
ResourceType:
type: string
enum: [exercise, program, workoutTemplate, workoutHistory, mediaAsset]
ShareResourceType:
type: string
enum: [program, workoutTemplate]
ShareRecipientStatus:
type: string
enum: [pending, accepted, declined, revoked]
JsonObject:
type: object
additionalProperties: true
SyncPushRequest:
type: object
required: [items]
properties:
deviceId:
type: string
nullable: true
description: Required by the application use case for each valid item.
items:
type: array
items:
$ref: '#/components/schemas/SyncPushItem'
SyncPushItem:
type: object
required: [resourceType, clientId, schemaVersion, clientUpdatedAt, payload]
properties:
resourceType:
$ref: '#/components/schemas/ResourceType'
clientId:
type: string
schemaVersion:
type: integer
minimum: 1
clientUpdatedAt:
type: string
format: date-time
deletedAt:
type: string
format: date-time
nullable: true
payload:
$ref: '#/components/schemas/JsonObject'
SyncPushResponse:
type: object
required: [serverCursor, results]
properties:
serverCursor:
type: string
format: date-time
results:
type: array
items:
$ref: '#/components/schemas/SyncPushResultItem'
SyncPushResultItem:
type: object
required: [status]
properties:
resourceType:
type: string
nullable: true
clientId:
type: string
nullable: true
serverId:
type: string
nullable: true
status:
type: string
enum: [accepted, ignoredOlder, error]
serverUpdatedAt:
type: string
format: date-time
nullable: true
message:
type: string
SyncPullResponse:
type: object
required: [serverCursor, items]
properties:
serverCursor:
type: string
format: date-time
items:
type: array
items:
$ref: '#/components/schemas/SyncedResourceItem'
SyncExchangeRequest:
allOf:
- $ref: '#/components/schemas/SyncPushRequest'
- type: object
properties:
since:
type: string
format: date-time
nullable: true
SyncExchangeResponse:
type: object
required: [serverCursor, pushResults, items]
properties:
serverCursor:
type: string
format: date-time
pushResults:
type: array
items:
$ref: '#/components/schemas/SyncPushResultItem'
items:
type: array
items:
$ref: '#/components/schemas/SyncedResourceItem'
SyncedResourceItem:
type: object
required:
- resourceType
- clientId
- serverId
- schemaVersion
- clientUpdatedAt
- serverUpdatedAt
- payload
properties:
resourceType:
$ref: '#/components/schemas/ResourceType'
clientId:
type: string
serverId:
type: string
schemaVersion:
type: integer
minimum: 1
clientUpdatedAt:
type: string
format: date-time
serverUpdatedAt:
type: string
format: date-time
deletedAt:
type: string
format: date-time
nullable: true
payload:
$ref: '#/components/schemas/JsonObject'
CreateShareRequest:
type: object
required: [resourceType, payload, recipientEmails]
properties:
resourceType:
$ref: '#/components/schemas/ShareResourceType'
payload:
$ref: '#/components/schemas/JsonObject'
recipientEmails:
type: array
minItems: 1
items:
type: string
format: email
CreateShareResponse:
type: object
required: [shareId, recipientUserIds, unresolvedEmails]
properties:
shareId:
type: string
recipientUserIds:
type: array
items:
type: string
unresolvedEmails:
type: array
items:
type: string
format: email
ShareInboxResponse:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: '#/components/schemas/ShareInboxItem'
ShareInboxItem:
type: object
required:
- shareId
- senderUserId
- resourceType
- payload
- status
- createdAt
properties:
shareId:
type: string
senderUserId:
type: string
resourceType:
$ref: '#/components/schemas/ShareResourceType'
payload:
$ref: '#/components/schemas/JsonObject'
status:
$ref: '#/components/schemas/ShareRecipientStatus'
createdAt:
type: string
format: date-time
respondedAt:
type: string
format: date-time
nullable: true
AcceptShareResponse:
type: object
required: [createdResource]
properties:
createdResource:
$ref: '#/components/schemas/SyncedResourceItem'

View File

@ -0,0 +1,289 @@
import 'dart:convert';
import 'package:gametime_server/api/auth_api.dart';
import 'package:gametime_server/api/router.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(
'register endpoint returns the created user without password data',
() async {
final users = _FakeUserRepository();
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/register'),
body: jsonEncode({
'email': ' USER@example.com ',
'password': 'password123',
'displayName': ' User ',
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 201);
expect(body, {'userId': 'id-1', 'email': 'user@example.com'});
expect(body.containsKey('password'), isFalse);
expect(body.containsKey('passwordHash'), isFalse);
},
);
test('register endpoint returns 409 when email is already used', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'existing-user',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/register'),
body: jsonEncode({
'email': 'user@example.com',
'password': 'password123',
}),
),
);
expect(response.statusCode, 409);
});
test('login endpoint returns a token and expiration', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/login'),
body: jsonEncode({
'email': 'user@example.com',
'password': 'password123',
'deviceLabel': 'phone',
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 200);
expect(body['token'], 'clear-token');
expect(body['expiresAt'], '2026-08-18T12:00:00.000Z');
});
test('login endpoint returns 401 for invalid credentials', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/login'),
body: jsonEncode({
'email': 'user@example.com',
'password': 'wrong-password',
}),
),
);
expect(response.statusCode, 401);
});
test('logout endpoint revokes the bearer session', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final sessions = _FakeAuthSessionRepository();
await sessions.insert(
AuthSession(
id: 'session-1',
userId: 'user-1',
tokenHash: 'token-hash:clear-token',
issuedAt: DateTime.utc(2026, 7, 19, 12),
expiresAt: DateTime.utc(2026, 7, 20, 12),
),
);
final handler = buildApiHandler(
authApi: _authApi(users: users, sessions: sessions),
);
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/logout'),
headers: {'authorization': 'Bearer clear-token'},
),
);
expect(response.statusCode, 204);
expect(sessions.revokedSessionIds, ['session-1']);
});
}
AuthApi _authApi({
_FakeUserRepository? users,
_FakeAuthSessionRepository? sessions,
}) {
final userRepository = users ?? _FakeUserRepository();
final sessionRepository = sessions ?? _FakeAuthSessionRepository();
final passwordHasher = _FakePasswordHasher();
final tokens = _FakeTokenService();
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
final ids = _FakeIds();
final authenticateRequest = AuthenticateRequestUseCase(
users: userRepository,
sessions: sessionRepository,
tokens: tokens,
clock: clock,
);
return AuthApi(
registerUser: RegisterUserUseCase(
users: userRepository,
passwordHasher: passwordHasher,
clock: clock,
ids: ids,
),
login: LoginUseCase(
users: userRepository,
sessions: sessionRepository,
passwordHasher: passwordHasher,
tokens: tokens,
clock: clock,
ids: ids,
),
logout: LogoutUseCase(
sessions: sessionRepository,
tokens: tokens,
clock: clock,
),
authenticateRequest: authenticateRequest,
);
}
final class _FakeUserRepository implements UserRepository {
final byId = <String, UserAccount>{};
final byEmail = <String, UserAccount>{};
@override
Future<UserAccount?> findByEmail(String email) async {
return byEmail[email.trim().toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async => byId[id];
@override
Future<void> insert(UserAccount user) async {
byId[user.id] = user;
byEmail[user.email] = user;
}
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {}
}
final class _FakeAuthSessionRepository implements AuthSessionRepository {
final byTokenHash = <String, AuthSession>{};
final revokedSessionIds = <String>[];
@override
Future<void> insert(AuthSession session) async {
byTokenHash[session.tokenHash] = session;
}
@override
Future<AuthSession?> findByTokenHash(String tokenHash) async {
return byTokenHash[tokenHash];
}
@override
Future<void> revoke({
required String sessionId,
required DateTime revokedAt,
}) async {
revokedSessionIds.add(sessionId);
}
}
final class _FakePasswordHasher implements PasswordHasher {
@override
Future<String> hash(String password) async => 'hashed:$password';
@override
Future<bool> verify({
required String password,
required String passwordHash,
}) async {
return passwordHash == 'hashed:$password';
}
}
final class _FakeTokenService implements OpaqueTokenService {
@override
String generateToken() => 'clear-token';
@override
String hashToken(String token) => 'token-hash:$token';
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}