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:
@ -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.
|
||||
@ -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.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#54"
|
||||
version: 2
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784449895057
|
||||
updatedAt: 1784451193831
|
||||
---
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
104
server/docs/integration-checklist.md
Normal file
104
server/docs/integration-checklist.md
Normal 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
591
server/openapi.yaml
Normal 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'
|
||||
289
server/test/auth_api_test.dart
Normal file
289
server/test/auth_api_test.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user