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

Fusionne feature/server-53-openapi-tests — dart analyze clean, dart
test 29/29 vert. openapi.yaml vérifié contre les endpoints réels du
serveur. Clôture le chantier serveur (tickets #47 à #53).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 15:56:39 +02:00
9 changed files with 1013 additions and 16 deletions

View File

@ -1,6 +1,7 @@
---
issueRef: "#52"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784412011625
version: 3
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784450444550
---
Dockerfile multi-stage (build `dart:stable` → runtime `debian:bookworm-slim`, `dart compile exe` AOT pour `server` et `migrate`), `docker-compose.yaml` (services `api`+`postgres:16-alpine`, healthcheck Postgres, `depends_on: service_healthy`, migrations auto au démarrage via `docker-entrypoint.sh`+`MIGRATE_ON_STARTUP`), `.env.example` documenté, `scripts/push-gitea-image.sh` sans aucun identifiant en dur (variables `GITEA_REGISTRY/OWNER/IMAGE_NAME/USERNAME/TOKEN`, login via --password-stdin). `docker compose config` validé (syntaxe/interpolation correctes), Dockerfile et scripts relus manuellement. Non vérifié : aucun `docker build`/`docker compose up` réel (pas d'accès Docker daemon dans le sandbox Main). À faire avant mise en prod : build réel de l'image, `docker compose up -d` complet avec vraie connexion Postgres, puis test du script de push une fois l'adresse Gitea fournie par l'utilisateur.

View File

@ -2,15 +2,15 @@
id: "28ebc668-c209-4e6a-b123-252a38a3c784"
number: 52
title: "[Server] Packaging Docker Compose et push Gitea Registry"
status: "open"
status: "qa"
priority: "low"
sprint: null
links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}]
agentRefs: []
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: 1784412011625
updatedAt: 1784412011625
version: 1
updatedAt: 1784450444550
version: 3
---
Finaliser l'exploitation container : `server/docker-compose.yaml` paramétrable via `.env` pour host bind, port API interne/externe, origine reverse proxy, URL publique HTTPS, config PostgreSQL, secrets, volumes persistants. Ajouter `server/scripts/push-gitea-image.sh` acceptant registry, image, tag, username/token via variables d'environnement ou arguments, sans valeur en dur. Documenter le flux build/push/run.

View File

@ -1,6 +1,6 @@
---
issueRef: "#54"
version: 2
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784449895057
updatedAt: 1784451193831
---

View File

@ -6,10 +6,11 @@ status: "open"
priority: "medium"
sprint: "1bb8bdf2-9c35-4f53-9a31-1390a47bec63"
links: []
agentRefs: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784449895047
updatedAt: 1784449895057
version: 2
updatedAt: 1784451193831
version: 4
---
Dans certains exercices, il peut être interessant qu'il y ai plusieurs étapes. Par exemple, pour un exercice de dribble, il peut être interessant de dire "dribbler main droite pendant 10 sec, puis dribller main gauche pendant 10 secondes, effectuer un double pas et réaliser 10 pompes" par exemple. Le tout représente alors une répétition mais se décompose en 4 étapes avec deux étapes qui se base sur un temps, et deux étapes sur un nombre de répétitions. J'aimerais donc pouvoir définir des étapes pour chaque exercice. L'ensemble de toutes les étape de l'exercice forme une répetition d'une série. Comme pour une série, chacune des étape doit pouvoir avoir un temps à définir (avec une valeur par défaut obligatoire > 0) ou un nombre de répétitions. Dans le cas d'une étape avec un temps, un chronometre est lancé. Dans les 3 dernieres secondes, un bip retenti pour chaque seconde et quand le temps arrive à 0 le bip est plus long que les autres. dans le cas ou deux étapes avec un temps s'enchainent, le second chronometre part directement à la fin de l'etape précédent. Le but est d'enchainer les étapes, il n'y a pas de pauses entre les différentes étapes. On eput aussi proposer de renseigner un score pour chaque étape de la même façon que pour les série.

View File

@ -587,11 +587,11 @@
"issueRef": "#52",
"path": "52",
"title": "[Server] Packaging Docker Compose et push Gitea Registry",
"status": "open",
"status": "qa",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784412011625
"updatedAt": 1784450444550
},
{
"issueRef": "#53",
@ -610,8 +610,10 @@
"status": "open",
"priority": "medium",
"sprint": "1bb8bdf2-9c35-4f53-9a31-1390a47bec63",
"assignedAgentIds": [],
"updatedAt": 1784449895057
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784451193831
}
]
}

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';
}
}