diff --git a/server/README.md b/server/README.md
index ef36ae9..af8253e 100644
--- a/server/README.md
+++ b/server/README.md
@@ -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 or open it with
+a local OpenAPI-compatible viewer.
+
## Docker Deployment
Ticket #52 adds Docker packaging for a headless Linux deployment. TLS is not
diff --git a/server/docs/integration-checklist.md b/server/docs/integration-checklist.md
new file mode 100644
index 0000000..7c1b8fd
--- /dev/null
+++ b/server/docs/integration-checklist.md
@@ -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.
diff --git a/server/openapi.yaml b/server/openapi.yaml
new file mode 100644
index 0000000..5974096
--- /dev/null
+++ b/server/openapi.yaml
@@ -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'
diff --git a/server/test/auth_api_test.dart b/server/test/auth_api_test.dart
new file mode 100644
index 0000000..0ae0ede
--- /dev/null
+++ b/server/test/auth_api_test.dart
@@ -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 = {};
+ final byEmail = {};
+
+ @override
+ Future findByEmail(String email) async {
+ return byEmail[email.trim().toLowerCase()];
+ }
+
+ @override
+ Future findById(String id) async => byId[id];
+
+ @override
+ Future insert(UserAccount user) async {
+ byId[user.id] = user;
+ byEmail[user.email] = user;
+ }
+
+ @override
+ Future updatePasswordHash({
+ required String userId,
+ required String passwordHash,
+ required DateTime updatedAt,
+ }) async {}
+}
+
+final class _FakeAuthSessionRepository implements AuthSessionRepository {
+ final byTokenHash = {};
+ final revokedSessionIds = [];
+
+ @override
+ Future insert(AuthSession session) async {
+ byTokenHash[session.tokenHash] = session;
+ }
+
+ @override
+ Future findByTokenHash(String tokenHash) async {
+ return byTokenHash[tokenHash];
+ }
+
+ @override
+ Future revoke({
+ required String sessionId,
+ required DateTime revokedAt,
+ }) async {
+ revokedSessionIds.add(sessionId);
+ }
+}
+
+final class _FakePasswordHasher implements PasswordHasher {
+ @override
+ Future hash(String password) async => 'hashed:$password';
+
+ @override
+ Future 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';
+ }
+}