Compare commits
No commits in common. "main" and "fix/upload-artifact-ghes-compat" have entirely different histories.
main
...
fix/upload
172 changed files with 1440 additions and 10325 deletions
19
.env.example
19
.env.example
|
|
@ -15,14 +15,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
|
|
||||||
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
|
|
||||||
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
|
|
||||||
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
|
|
||||||
# building locally instead (`docker compose build`, or `up` with no image
|
|
||||||
# present) — see docker-compose.yml's `image:`/`build:` comment.
|
|
||||||
# IMAGE_TAG=latest
|
|
||||||
|
|
||||||
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
||||||
# All optional: an instance that doesn't run the admin app needs none of
|
# All optional: an instance that doesn't run the admin app needs none of
|
||||||
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||||
|
|
@ -40,17 +32,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
# ADMIN_INITIAL_NAME=Ops
|
||||||
#
|
|
||||||
# Optional — lets an admin open a Forgejo ticket from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). All three needed together: the
|
|
||||||
# button stays hidden and the endpoint answers 503 unless all are set to a
|
|
||||||
# non-empty value. docker-compose.yml forwards these into the "app"
|
|
||||||
# container, so setting them here is enough (a blank value counts as
|
|
||||||
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
|
|
||||||
# scope — generate it in Forgejo, never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
||||||
# Optional — only set this to false if THIS deployment is served over
|
# Optional — only set this to false if THIS deployment is served over
|
||||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||||
|
|
|
||||||
71
.github/workflows/ci.yml
vendored
71
.github/workflows/ci.yml
vendored
|
|
@ -39,13 +39,6 @@ env:
|
||||||
# services/tech-step-intent-service/Dockerfile from this checkout.
|
# services/tech-step-intent-service/Dockerfile from this checkout.
|
||||||
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
||||||
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
|
|
||||||
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
|
|
||||||
# in those tests, so no real request ever leaves CI; these values only need
|
|
||||||
# to parse (a valid URL and an `owner/repo`).
|
|
||||||
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
|
|
||||||
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
|
|
||||||
FORGEJO_REPO: "ci/batchcooking"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Five independent jobs, no needs: between them — each starts in parallel
|
# Five independent jobs, no needs: between them — each starts in parallel
|
||||||
|
|
@ -68,70 +61,17 @@ jobs:
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
|
|
||||||
# spent queued in "Wait for any earlier test job" below. Also a runaway
|
|
||||||
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
|
|
||||||
# leaked handle would otherwise hang the job until the runner's global
|
|
||||||
# timeout (a `test` job zombied ~1h once). If a hung job hits this
|
|
||||||
# ceiling the runner removes its container, which unblocks the queue.
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install curl and the docker CLI
|
- name: Install curl and the docker CLI
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y curl docker.io
|
apt-get update && apt-get install -y curl docker.io
|
||||||
|
|
||||||
# Serialize `test` across every concurrent run. Two `test` jobs must
|
|
||||||
# never overlap: they share one `--name postgres` container on the
|
|
||||||
# runner's shared docker network (next step), so their `prisma migrate
|
|
||||||
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
|
|
||||||
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
|
|
||||||
# pending migrations" when there should be some, `tech_step`/`diet` id
|
|
||||||
# shifts breaking unrelated assertions; and even past that they starve
|
|
||||||
# each other's RAM/CPU (each also trains a spaCy model) until
|
|
||||||
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
|
|
||||||
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
|
|
||||||
# re-trigger once the runner was idle.
|
|
||||||
#
|
|
||||||
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
|
|
||||||
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
|
|
||||||
# 1.26 and is silently ignored here. A lock object (network, container,
|
|
||||||
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
|
|
||||||
# trailing `if: always()` step after an earlier step fails, so there is
|
|
||||||
# no reliable in-job release. Instead, key off the one thing the runner
|
|
||||||
# *does* always clean up — the job container itself (removed on pass,
|
|
||||||
# fail, and cancel alike): wait while any lower-numbered `test` job's
|
|
||||||
# container is still running. Lower task id goes first, so two jobs
|
|
||||||
# that start together can't deadlock; nothing to release.
|
|
||||||
- name: Wait for any earlier test job
|
|
||||||
run: |
|
|
||||||
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
|
|
||||||
echo "this test job is task ${my_id:-<unknown, not serializing>}"
|
|
||||||
# ~25 min cap (75 * 20s): comfortably covers one earlier run
|
|
||||||
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
|
|
||||||
# wait exceeds this just fails and gets an empty-commit re-run.
|
|
||||||
for attempt in $(seq 1 75); do
|
|
||||||
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
|
|
||||||
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
|
|
||||||
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
|
|
||||||
if [ -z "$earlier" ]; then
|
|
||||||
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
|
|
||||||
sleep 20
|
|
||||||
done
|
|
||||||
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# Postgres as a plain background container instead of a `services:`
|
# Postgres as a plain background container instead of a `services:`
|
||||||
# block — see this workflow's top-level `env.DATABASE_URL` comment for
|
# block — see this workflow's top-level `env.DATABASE_URL` comment for
|
||||||
# why. Reachable as `postgres` from this job (the runner mounts the
|
# why. Reachable as `postgres` from this job (the runner mounts the
|
||||||
# docker socket and runs jobs on a shared user-defined network, so a
|
# docker socket and runs jobs on a shared user-defined network, so a
|
||||||
# `--name postgres` sibling resolves by name); the network is
|
# `--name postgres` sibling resolves by name); the network is
|
||||||
# discovered from the job container itself rather than hardcoded. The
|
# discovered from the job container itself rather than hardcoded.
|
||||||
# `docker rm -f postgres` below also clears a container left behind by
|
|
||||||
# the previous run (this runner has no reliable post-job cleanup), and
|
|
||||||
# the serialization step above means no other run is using it now.
|
|
||||||
- name: Start Postgres in the background
|
- name: Start Postgres in the background
|
||||||
run: |
|
run: |
|
||||||
net="$(docker inspect "$(hostname)" \
|
net="$(docker inspect "$(hostname)" \
|
||||||
|
|
@ -139,15 +79,6 @@ jobs:
|
||||||
| awk '{print $1}')"
|
| awk '{print $1}')"
|
||||||
echo "job network: ${net:?could not determine the job container network}"
|
echo "job network: ${net:?could not determine the job container network}"
|
||||||
docker rm -f postgres >/dev/null 2>&1 || true
|
docker rm -f postgres >/dev/null 2>&1 || true
|
||||||
# Also drop any `postgres-<suffix>` container. A since-reverted
|
|
||||||
# attempt at per-run Postgres naming on this branch left some
|
|
||||||
# running on this persistent runner (their `if: always()` teardown
|
|
||||||
# never fired — this runner skips trailing steps after a failure),
|
|
||||||
# and nothing else removes them. A second idle Postgres here starves
|
|
||||||
# the runner's RAM and slows every query ~5x → `resetDatabase()`
|
|
||||||
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
|
|
||||||
# more, so this only ever clears that debris.
|
|
||||||
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
|
||||||
docker run -d --name postgres --network "$net" \
|
docker run -d --name postgres --network "$net" \
|
||||||
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
|
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
|
||||||
postgres:16-alpine
|
postgres:16-alpine
|
||||||
|
|
|
||||||
239
.github/workflows/release.yml
vendored
239
.github/workflows/release.yml
vendored
|
|
@ -1,243 +1,44 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# On a `vX.Y.Z` tag: build the three service images and push them to this
|
# No image registry involved: Portainer is wired to this repo's Git remote
|
||||||
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
|
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
||||||
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
|
# that the image actually builds at the tagged commit, publishes a GitHub
|
||||||
# and `latest`), build+attach a signed Android APK, publish a Forgejo
|
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
|
||||||
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
|
# have to wait for its own polling interval.
|
||||||
# pull those prebuilt images (docker-compose.yml references them via
|
|
||||||
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
|
|
||||||
# keeps a `build:` fallback next to every `image:`.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
|
|
||||||
# (the `build-and-push-images` job below `docker login`s with it). If a
|
|
||||||
# future instance policy scopes the run token tighter, swap that login's
|
|
||||||
# password for a `write:package` PAT added as a repo secret.
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Build the three service images at the tagged commit and push them to the
|
sanity-build:
|
||||||
# Forgejo container registry. A successful push also *is* the "does it
|
|
||||||
# still build" check the old `sanity-build` job did (which only covered
|
|
||||||
# apps/api). One sequential job, not a per-image matrix: three concurrent
|
|
||||||
# `docker build`s hammer this single persistent runner's Docker daemon
|
|
||||||
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
|
|
||||||
# for no real gain on a release, which is rare and usually runs alone.
|
|
||||||
build-and-push-images:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# This runner's job containers don't come with docker preinstalled —
|
- uses: actions/checkout@v4
|
||||||
# same reason ci.yml's `test` job has to do this before its own
|
|
||||||
# `docker run` calls.
|
|
||||||
- name: Install docker
|
|
||||||
run: apt-get update && apt-get install -y docker.io
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
# Build only, no push, no registry — just confirms Portainer will be
|
||||||
|
# able to build this same Dockerfile successfully at this tag before
|
||||||
|
# anyone points it at this ref.
|
||||||
|
- run: docker build -f apps/api/Dockerfile .
|
||||||
|
|
||||||
- name: Build et push des 3 images vers le registre Forgejo
|
github-release:
|
||||||
env:
|
|
||||||
# The per-run token Forgejo Actions mints — has `packages: write`
|
|
||||||
# via this workflow's `permissions:` block. `docker login` accepts
|
|
||||||
# it as the password for the actor who pushed the tag.
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
|
|
||||||
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
|
|
||||||
# registry namespace is the owner, lower-cased (Forgejo package
|
|
||||||
# names are case-insensitive but the CLI wants a stable form).
|
|
||||||
registry="${GITHUB_SERVER_URL#http*://}"
|
|
||||||
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
tag="$GITHUB_REF_NAME"
|
|
||||||
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
|
|
||||||
|
|
||||||
# Every Dockerfile's build context is the repo root (see
|
|
||||||
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
|
|
||||||
build_push() {
|
|
||||||
image="$registry/$owner/$1"
|
|
||||||
echo "::group::$1"
|
|
||||||
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
|
|
||||||
docker push "$image:$tag"
|
|
||||||
docker push "$image:latest"
|
|
||||||
echo "::endgroup::"
|
|
||||||
}
|
|
||||||
build_push batchcooking-app apps/api/Dockerfile
|
|
||||||
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
|
|
||||||
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
|
|
||||||
|
|
||||||
# Builds the same signed APK as ci.yml's build-android job (issue #64),
|
|
||||||
# with one difference: the version baked into the APK is derived from the
|
|
||||||
# tag itself rather than left at build.gradle's default. Duplicated here
|
|
||||||
# rather than factored into a shared `workflow_call` reusable workflow —
|
|
||||||
# deliberate: reusable-workflow support on this self-hosted Forgejo/act
|
|
||||||
# runner is unverified, and this ~15-line recipe is proven working as-is
|
|
||||||
# in ci.yml; not worth risking a new class of runner-compatibility surprise
|
|
||||||
# (this repo has already hit several — see ci-forgejo-runner-constraints
|
|
||||||
# in project memory) to save a little duplication. Keep both copies in
|
|
||||||
# sync if the Android build steps ever change.
|
|
||||||
build-android-apk:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: sanity-build
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: softprops/action-gh-release@v2
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
# Auto-generated from merged PRs since the previous tag — no
|
||||||
cache: pnpm
|
# changelog tooling to maintain, consistent with there being no
|
||||||
|
# versioning tooling elsewhere in the repo yet.
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
generate_release_notes: true
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- uses: https://github.com/android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build le bundle web pour Capacitor
|
|
||||||
env:
|
|
||||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
|
||||||
run: pnpm --filter web build:capacitor
|
|
||||||
- run: pnpm --filter web exec cap sync android
|
|
||||||
|
|
||||||
- name: Décode le keystore de release
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
|
||||||
|
|
||||||
- name: Dérive versionName/versionCode depuis le tag
|
|
||||||
# GITHUB_REF_NAME = "v1.4.2" pour un tag `v1.4.2` — versionName =
|
|
||||||
# "1.4.2" (sans le `v`), versionCode = majeur*10000 + mineur*100 +
|
|
||||||
# correctif (ex. 1.4.2 -> 10402), un entier strictement croissant à
|
|
||||||
# chaque release comme Android l'exige (jamais réutilisé, jamais
|
|
||||||
# décroissant) tant que chaque partie reste sous 100 (largement
|
|
||||||
# suffisant ici). Exposé aux steps suivants via $GITHUB_ENV plutôt
|
|
||||||
# que recalculé.
|
|
||||||
run: |
|
|
||||||
tag="${GITHUB_REF_NAME#v}"
|
|
||||||
major="$(echo "$tag" | cut -d. -f1)"
|
|
||||||
minor="$(echo "$tag" | cut -d. -f2)"
|
|
||||||
patch="$(echo "$tag" | cut -d. -f3)"
|
|
||||||
# Préfixe 10# : force une lecture en base 10, sinon bash lirait
|
|
||||||
# un composant à zéro non significatif (ex. "08") comme de
|
|
||||||
# l'octal invalide et échouerait.
|
|
||||||
code=$((10#$major * 10000 + 10#$minor * 100 + 10#$patch))
|
|
||||||
echo "Version dérivée du tag $GITHUB_REF_NAME : versionName=$tag versionCode=$code"
|
|
||||||
echo "ANDROID_VERSION_NAME=$tag" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_VERSION_CODE=$code" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build l'APK signé
|
|
||||||
working-directory: apps/web/android
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PATH: release.keystore
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
chmod +x ./gradlew
|
|
||||||
./gradlew assembleRelease -PandroidVersionName="$ANDROID_VERSION_NAME" -PandroidVersionCode="$ANDROID_VERSION_CODE"
|
|
||||||
|
|
||||||
# Nom de fichier stable, jamais versionné : la page d'accueil (#65)
|
|
||||||
# pointe vers /releases/latest (Forgejo n'a pas l'équivalent du
|
|
||||||
# raccourci GitHub /releases/latest/download/<fichier>, seul
|
|
||||||
# /releases/latest lui-même résout) — un nom stable évite au moins que
|
|
||||||
# l'utilisateur voie un nom de fichier différent à chaque release une
|
|
||||||
# fois sur la page.
|
|
||||||
- name: Renomme l'APK sous un nom stable
|
|
||||||
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
|
|
||||||
|
|
||||||
# v4+ exige l'API "Results" de GitHub, non supportée par ce Forgejo
|
|
||||||
# self-hosted ("GHESNotSupportedError") — v3 (ancienne API Artifacts)
|
|
||||||
# est la seule utilisable ici, voir ci.yml's build-android pour la
|
|
||||||
# même contrainte déjà rencontrée.
|
|
||||||
- uses: https://github.com/actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking.apk
|
|
||||||
path: batchcooking.apk
|
|
||||||
|
|
||||||
forgejo-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-and-push-images, build-android-apk]
|
|
||||||
steps:
|
|
||||||
# Ce runner ne fournit ni docker ni curl par défaut dans ses
|
|
||||||
# conteneurs de job — même constat déjà fait pour docker (voir
|
|
||||||
# build-and-push-images) et pour curl dans ci.yml's test job.
|
|
||||||
- name: Install curl
|
|
||||||
run: apt-get update && apt-get install -y curl
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking.apk
|
|
||||||
|
|
||||||
# softprops/action-gh-release (l'action GitHub habituelle pour ça)
|
|
||||||
# échoue systématiquement contre ce Forgejo : sa requête de création
|
|
||||||
# de release répond 405 (vérifié en conditions réelles, tag v0.2.0 —
|
|
||||||
# pas un souci de ref bare comme les autres actions de ce fichier,
|
|
||||||
# une vraie incompatibilité d'API entre les deux plateformes qui n'a
|
|
||||||
# pas de contournement connu côté configuration de l'action). Appelle
|
|
||||||
# directement l'API native de Forgejo plutôt qu'une action tierce
|
|
||||||
# dont la compatibilité GitHub-Forgejo n'est pas garantie.
|
|
||||||
#
|
|
||||||
# Pas d'équivalent à `generate_release_notes` ici (fonctionnalité
|
|
||||||
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
|
|
||||||
# release reste minimal, cohérent avec l'absence d'outillage de
|
|
||||||
# changelog ailleurs dans le repo.
|
|
||||||
- name: Crée (ou récupère) la Release Forgejo
|
|
||||||
id: create-release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
|
|
||||||
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
|
|
||||||
|
|
||||||
# POST pour créer la release, code HTTP capturé à part. Si le tag est
|
|
||||||
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
|
|
||||||
# ré-exécution du job), l'API répond 409 : on réutilise alors la
|
|
||||||
# release existante au lieu d'échouer — idempotence, cf. l'incident
|
|
||||||
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
|
|
||||||
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$body")"
|
|
||||||
|
|
||||||
if [ "$code" = "409" ]; then
|
|
||||||
echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
|
|
||||||
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
|
|
||||||
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
|
||||||
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
|
|
||||||
if [ -z "$id" ]; then
|
|
||||||
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Release $GITHUB_REF_NAME prête (id=$id)"
|
|
||||||
echo "release-id=$id" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Attache l'APK à la Release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/${{ steps.create-release.outputs.release-id }}/assets"
|
|
||||||
curl -fsS -X POST "$api?name=batchcooking.apk" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-F "attachment=@batchcooking.apk"
|
|
||||||
|
|
||||||
notify-portainer:
|
notify-portainer:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: forgejo-release
|
needs: github-release
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Portainer redeploy
|
- name: Trigger Portainer redeploy
|
||||||
# Best-effort: does nothing (and doesn't fail the workflow) until
|
# Best-effort: does nothing (and doesn't fail the workflow) until
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -207,18 +207,10 @@ depuis la même origine. Les deux autres services du fichier
|
||||||
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
||||||
au host — voir leurs propres README pour leur rôle.
|
au host — voir leurs propres README pour leur rôle.
|
||||||
|
|
||||||
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
|
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||||
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
|
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||||
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
|
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||||||
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`)
|
donc aucune image nulle part.
|
||||||
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
|
|
||||||
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
|
|
||||||
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
|
|
||||||
images pré-construites, `docker compose up`/`build` retombe sur un build local si
|
|
||||||
elles sont absentes. L'instance **Portainer** de production peut donc désormais
|
|
||||||
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
|
|
||||||
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
|
|
||||||
deux modes fonctionnent avec le même `docker-compose.yml`.
|
|
||||||
|
|
||||||
### Release (`.github/workflows/release.yml`)
|
### Release (`.github/workflows/release.yml`)
|
||||||
|
|
||||||
|
|
@ -229,16 +221,14 @@ git tag vX.Y.Z
|
||||||
git push --tags
|
git push --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
|
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
||||||
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
|
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||||||
réussi vaut aussi vérification que tout build encore à ce commit),
|
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||||||
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
|
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
||||||
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
|
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
||||||
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
|
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
||||||
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
|
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||||||
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
|
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||||||
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
|
|
||||||
dépôt `PORTAINER_WEBHOOK_URL`.
|
|
||||||
|
|
||||||
## Application mobile (Android)
|
## Application mobile (Android)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# Optional — defaults shown (see src/config/env.ts)
|
# Optional — defaults shown (see src/config/env.ts)
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173,https://localhost
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Required — services/tech-step-intent-service must be running locally (see
|
# Required — services/tech-step-intent-service must be running locally (see
|
||||||
|
|
@ -35,12 +35,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
# ADMIN_INITIAL_NAME=Ops
|
||||||
|
|
||||||
# Only needed to create a Forgejo issue from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). The button is hidden and the
|
|
||||||
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
|
|
||||||
# personal access token with the write:issue scope — generate it in Forgejo,
|
|
||||||
# never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Contact-form messages (issue #80). New table only — nothing to backfill.
|
|
||||||
-- `author_id` is nullable with ON DELETE SET NULL so a report survives its
|
|
||||||
-- sender's account deletion (same as ingredients.created_by_id).
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "contact_message" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"author_id" INTEGER,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"message" TEXT NOT NULL,
|
|
||||||
"page_url" TEXT,
|
|
||||||
"user_agent" TEXT,
|
|
||||||
"app_version" TEXT,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Link a contact message to the Forgejo issue an admin created from it
|
|
||||||
-- (issue #81). Two nullable columns, no backfill — existing rows have no
|
|
||||||
-- linked issue.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "contact_message"
|
|
||||||
ADD COLUMN "forgejo_issue_number" INTEGER,
|
|
||||||
ADD COLUMN "forgejo_issue_url" TEXT;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) :
|
|
||||||
-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement
|
|
||||||
-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est
|
|
||||||
-- réécrite.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;
|
|
||||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-- Régimes d'une recette calculés depuis ses ingrédients (issue #131, puce 4
|
|
||||||
-- de #3) — `RecipeDiet` (tag manuel) est supprimé : `RecipeSummaryView.diets`
|
|
||||||
-- est désormais dérivé de `IngredientDiet` + de l'allergène `gluten` côté
|
|
||||||
-- lecture (voir `computeRecipeDietViews`). Aucune donnée à conserver.
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "recipe_diet";
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- AlterTable: off-catalog technique "placeholder" rows (issue #130, puce 3
|
|
||||||
-- de #3) — a user names a technique the catalog is missing while correcting a
|
|
||||||
-- step. Every existing row is a real seeded catalog entry, so the flag
|
|
||||||
-- defaults to false and the four new nullable columns stay NULL for them —
|
|
||||||
-- no backfill needed. Mirrors the `ingredients` placeholder migration.
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "display_name" TEXT;
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "created_by_id" INTEGER;
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "created_at" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "reviewed_at" TIMESTAMP(3);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "tech_step_is_placeholder_idx" ON "tech_step"("is_placeholder");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step" ADD CONSTRAINT "tech_step_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -49,6 +49,7 @@ model Diet {
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
users UserProfile[]
|
users UserProfile[]
|
||||||
|
recipes RecipeDiet[]
|
||||||
ingredients IngredientDiet[]
|
ingredients IngredientDiet[]
|
||||||
|
|
||||||
@@map("diet")
|
@@map("diet")
|
||||||
|
|
@ -134,11 +135,6 @@ model UserProfile {
|
||||||
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
||||||
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
||||||
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
||||||
/// Placeholder `TechStep` rows this profile proposed while correcting a step
|
|
||||||
/// — see `TechStep.isPlaceholder`.
|
|
||||||
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
|
|
||||||
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
|
||||||
contactMessages ContactMessage[]
|
|
||||||
|
|
||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
@ -377,6 +373,7 @@ model Recipe {
|
||||||
steps Step[]
|
steps Step[]
|
||||||
planningItems PlanningItem[]
|
planningItems PlanningItem[]
|
||||||
favoritedBy RecipeFavorite[]
|
favoritedBy RecipeFavorite[]
|
||||||
|
diets RecipeDiet[]
|
||||||
|
|
||||||
@@unique([sourceId, externalId])
|
@@unique([sourceId, externalId])
|
||||||
@@map("recipe")
|
@@map("recipe")
|
||||||
|
|
@ -397,6 +394,21 @@ model RecipeFavorite {
|
||||||
@@map("recipe_favorite")
|
@@map("recipe_favorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||||
|
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||||
|
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||||
|
/// from its ingredients.
|
||||||
|
model RecipeDiet {
|
||||||
|
recipeId Int @map("recipe_id")
|
||||||
|
dietId Int @map("diet_id")
|
||||||
|
|
||||||
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([recipeId, dietId])
|
||||||
|
@@map("recipe_diet")
|
||||||
|
}
|
||||||
|
|
||||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||||
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
||||||
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
||||||
|
|
@ -686,17 +698,12 @@ model Unit {
|
||||||
model RecipeIngredient {
|
model RecipeIngredient {
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
ingredientId Int @map("ingredient_id")
|
ingredientId Int @map("ingredient_id")
|
||||||
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
/// salt, pepper… added to taste, with nothing to scale by portions or to
|
unitId Int @map("unit_id")
|
||||||
/// aggregate on a shopping list. The "both null or neither" invariant is
|
|
||||||
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
|
|
||||||
/// the schema — same posture as this file's other cross-field invariants.
|
|
||||||
quantity Decimal? @db.Decimal(10, 2)
|
|
||||||
unitId Int? @map("unit_id")
|
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
unit Unit? @relation(fields: [unitId], references: [id])
|
unit Unit @relation(fields: [unitId], references: [id])
|
||||||
|
|
||||||
@@id([recipeId, ingredientId])
|
@@id([recipeId, ingredientId])
|
||||||
@@map("recipe_ingredient")
|
@@map("recipe_ingredient")
|
||||||
|
|
@ -721,33 +728,6 @@ model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
/// `true` = a "placeholder" technique: a free-text technique a user typed
|
|
||||||
/// while correcting a step, because nothing in the seeded catalog matched
|
|
||||||
/// (issue #131 for the ingredient precedent this mirrors — see
|
|
||||||
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
|
|
||||||
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
|
|
||||||
/// training data of its own, and is excluded from
|
|
||||||
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
|
|
||||||
/// like any other technique (the correction that created it applies
|
|
||||||
/// immediately), but a maintainer reviews it at
|
|
||||||
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
|
|
||||||
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
|
|
||||||
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
|
||||||
/// The exact text the user typed — `null` for a real catalog row (whose
|
|
||||||
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
|
|
||||||
/// `isPlaceholder`, enforced service-side.
|
|
||||||
displayName String? @map("display_name")
|
|
||||||
/// Profile that first proposed this technique — context for the admin
|
|
||||||
/// review. `onDelete: SetNull` so deleting an account never blocks on the
|
|
||||||
/// recipes still using its placeholder. `null` for a real catalog row.
|
|
||||||
createdById Int? @map("created_by_id")
|
|
||||||
/// When this placeholder was proposed. `null` for a real catalog row.
|
|
||||||
createdAt DateTime? @map("created_at")
|
|
||||||
/// Stamped once a maintainer has triaged this proposal
|
|
||||||
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
|
|
||||||
/// `null` while pending / for a real catalog row.
|
|
||||||
reviewedAt DateTime? @map("reviewed_at")
|
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||||
|
|
@ -758,10 +738,7 @@ model TechStep {
|
||||||
/// Training-corpus suggestions targeting this technique — see
|
/// Training-corpus suggestions targeting this technique — see
|
||||||
/// `TechStepTrainingSuggestion`.
|
/// `TechStepTrainingSuggestion`.
|
||||||
trainingSuggestions TechStepTrainingSuggestion[]
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
/// The profile that proposed this row when it is a placeholder — see `createdById`.
|
|
||||||
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([isPlaceholder])
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1017,7 +994,7 @@ model StepTechStepCorrection {
|
||||||
///
|
///
|
||||||
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
||||||
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
||||||
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
|
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
|
||||||
/// proposed once for a human to read, not ids referencing another catalog
|
/// proposed once for a human to read, not ids referencing another catalog
|
||||||
/// table, so there's nothing for a join table to normalize against.
|
/// table, so there's nothing for a join table to normalize against.
|
||||||
model TechStepTrainingSuggestion {
|
model TechStepTrainingSuggestion {
|
||||||
|
|
@ -1212,47 +1189,3 @@ model TourProgress {
|
||||||
@@id([userProfileId, scenarioId])
|
@@id([userProfileId, scenarioId])
|
||||||
@@map("tour_progress")
|
@@map("tour_progress")
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Contact
|
|
||||||
// In-app "report a bug / suggestion" form — user-submitted, read-only for
|
|
||||||
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
|
|
||||||
// into a Forgejo issue).
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A message sent via the app's contact form (bug, suggestion, other).
|
|
||||||
/// Written once by a signed-in user, never edited afterwards — consulted
|
|
||||||
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
|
|
||||||
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
|
|
||||||
/// even if the account that sent it is deleted in the meantime.
|
|
||||||
model ContactMessage {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
authorId Int? @map("author_id")
|
|
||||||
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
|
|
||||||
/// `CONTACT_MESSAGE_TYPES` const (same approach as
|
|
||||||
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
|
|
||||||
/// value is one line, no Postgres enum migration.
|
|
||||||
type String
|
|
||||||
message String
|
|
||||||
/// Technical context captured automatically at submit time, for triage —
|
|
||||||
/// all nullable (an older client, or a direct API call, can omit them).
|
|
||||||
pageUrl String? @map("page_url")
|
|
||||||
userAgent String? @map("user_agent")
|
|
||||||
appVersion String? @map("app_version")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
/// Number of the Forgejo issue an admin created from this message via
|
|
||||||
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
|
|
||||||
/// "Créer un ticket" button disappears (one message links to at most one
|
|
||||||
/// issue).
|
|
||||||
forgejoIssueNumber Int? @map("forgejo_issue_number")
|
|
||||||
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
|
|
||||||
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
|
|
||||||
/// correct even if that config changes later.
|
|
||||||
forgejoIssueUrl String? @map("forgejo_issue_url")
|
|
||||||
|
|
||||||
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([createdAt])
|
|
||||||
@@map("contact_message")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,9 @@ import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { adminRouter } from "./modules/admin/admin.routes.js";
|
import { adminRouter } from "./modules/admin/admin.routes.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { contactRouter } from "./modules/contact/contact.routes.js";
|
|
||||||
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
|
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
|
|
@ -36,11 +34,10 @@ export function createServer(): ExpressServer {
|
||||||
// pipeline (its "finish" listener still fires for a request that never
|
// pipeline (its "finish" listener still fires for a request that never
|
||||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
// makes it past CORS/body-parsing, not just ones that reach a route).
|
||||||
server.addMiddleware(requestLogger);
|
server.addMiddleware(requestLogger);
|
||||||
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
|
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface
|
||||||
// config/env.ts for why the packaged Android app's origin must always be
|
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s
|
||||||
// one of them). The admin surface (`/admin/*`) is served by this same API
|
// own `/admin/*` routes — same origin as the rest of the app, so no
|
||||||
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
|
// extra CORS entry.
|
||||||
// rest of the app, so no extra CORS entry.
|
|
||||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||||
|
|
||||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
|
|
@ -61,20 +58,12 @@ export function createServer(): ExpressServer {
|
||||||
// admin page path can ever collide with an admin API path again.
|
// admin page path can ever collide with an admin API path again.
|
||||||
server.mountRouter("/admin/api", adminRouter);
|
server.mountRouter("/admin/api", adminRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
// In-app "signaler un bug / suggestion" form (issue #80) — session
|
|
||||||
// required, one POST. Admins read these rows via `/admin/api/contact`,
|
|
||||||
// never this router.
|
|
||||||
server.mountRouter("/contact", contactRouter);
|
|
||||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||||
// `requireInternalWorker` on every route within (see that router's own
|
// `requireInternalWorker` on every route within (see that router's own
|
||||||
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
||||||
// rather than nested under one of them since it isn't scoped to a single
|
// rather than nested under one of them since it isn't scoped to a single
|
||||||
// recipe/step the way `recipeRouter`'s own correction routes are.
|
// recipe/step the way `recipeRouter`'s own correction routes are.
|
||||||
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
||||||
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
|
|
||||||
// mobile.service.ts's doc comment for why this can't just be called
|
|
||||||
// directly from the browser.
|
|
||||||
server.mountRouter("/mobile", mobileRouter);
|
|
||||||
server.mountRouter("/planning", planningRouter);
|
server.mountRouter("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,12 @@ import { z } from "zod";
|
||||||
// separate test database this now requires.
|
// separate test database this now requires.
|
||||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
||||||
|
|
||||||
/**
|
|
||||||
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
|
|
||||||
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
|
|
||||||
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
|
|
||||||
* is `.dockerignore`d, so that passthrough is the container's only source
|
|
||||||
* of these. Fields that must *fail closed* on a blank value rather than run
|
|
||||||
* their own format check against `""` (and crash `parse()` at boot) wrap
|
|
||||||
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
|
|
||||||
* folds the same `"" -> undefined` step into its own `.transform`.
|
|
||||||
*/
|
|
||||||
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for every environment variable the API reads. Parsing (below)
|
* Schema for every environment variable the API reads. Parsing (below)
|
||||||
* fails fast at startup if something required is missing/invalid, instead
|
* fails fast at startup if something required is missing/invalid, instead
|
||||||
* of surfacing as a confusing runtime error later. Exported so the parsing
|
* of surfacing as a confusing runtime error later.
|
||||||
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
|
|
||||||
* without the process-wide `process.env` the singleton below parses.
|
|
||||||
*/
|
*/
|
||||||
export const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
/** Port the HTTP server listens on. */
|
/** Port the HTTP server listens on. */
|
||||||
|
|
@ -48,42 +34,8 @@ export const envSchema = z.object({
|
||||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||||
/** Name of the httpOnly cookie carrying the session JWT. */
|
/** Name of the httpOnly cookie carrying the session JWT. */
|
||||||
AUTH_COOKIE_NAME: z.string().default("session"),
|
AUTH_COOKIE_NAME: z.string().default("session"),
|
||||||
/**
|
/** Origin allowed by CORS — must match wherever apps/web is served from. */
|
||||||
* Origin(s) allowed by CORS, comma-separated — parsed below into a
|
CORS_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
|
|
||||||
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
|
|
||||||
* package) and reused as-is for the Socket.IO handshake's own CORS config
|
|
||||||
* (`server.ts`, which needs its own since the WS upgrade never runs
|
|
||||||
* through `setupCore`'s middleware).
|
|
||||||
*
|
|
||||||
* Must always include `https://localhost`: the packaged Android app's
|
|
||||||
* WebView is served from exactly that fixed origin (Capacitor's default
|
|
||||||
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
|
|
||||||
* doc comment), and every request it makes to this API — `/auth/login`
|
|
||||||
* and `/auth/signup` included — is genuinely cross-origin, unlike
|
|
||||||
* apps/web's *browser* build, which is served from the very same origin
|
|
||||||
* as this API in production (see docker-compose.yml's `app` service) and
|
|
||||||
* so never needs an entry here at all. Missing this origin doesn't fail
|
|
||||||
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
|
|
||||||
* for an origin it's about to reject (it only *omits*
|
|
||||||
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
|
|
||||||
* looks identical to a normal one right up until the browser silently
|
|
||||||
* drops the real `POST` — surfacing client-side as a generic "unexpected
|
|
||||||
* error" with no 401/403 anywhere to point at the actual cause. Default
|
|
||||||
* covers both the Vite dev server and the packaged app out of the box; a
|
|
||||||
* real deployment's own web origin (e.g. a production domain) never needs
|
|
||||||
* adding here for the reason above, but the env var still allows
|
|
||||||
* extending/overriding this list (comma-separated) if that ever changes.
|
|
||||||
*/
|
|
||||||
CORS_ORIGIN: z
|
|
||||||
.string()
|
|
||||||
.default("http://localhost:5173,https://localhost")
|
|
||||||
.transform((value) =>
|
|
||||||
value
|
|
||||||
.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter((origin) => origin.length > 0),
|
|
||||||
),
|
|
||||||
/**
|
/**
|
||||||
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
||||||
* alongside the API. Optional, no default — only set inside the
|
* alongside the API. Optional, no default — only set inside the
|
||||||
|
|
@ -157,26 +109,6 @@ export const envSchema = z.object({
|
||||||
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
||||||
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
||||||
ADMIN_INITIAL_NAME: z.string().optional(),
|
ADMIN_INITIAL_NAME: z.string().optional(),
|
||||||
/**
|
|
||||||
* Forgejo instance + credentials for "create an issue from a contact
|
|
||||||
* message" (`/admin/messages`, issue #81). All three `.optional()` and
|
|
||||||
* checked together by `ForgejoService.isConfigured()` — the feature
|
|
||||||
* **fails closed** when any is missing: the endpoint answers `503` and
|
|
||||||
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
|
|
||||||
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
|
||||||
* rest of the app never talks to Forgejo.
|
|
||||||
*/
|
|
||||||
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
|
|
||||||
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
|
|
||||||
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
|
|
||||||
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
|
|
||||||
FORGEJO_REPO: z.preprocess(
|
|
||||||
blankAsUnset,
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
|
||||||
.optional(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
import { logger } from "../logger.service.js";
|
|
||||||
|
|
||||||
/** The slice of Forgejo's `POST .../issues` response this integration reads. */
|
|
||||||
interface ForgejoIssueResponse {
|
|
||||||
number: number;
|
|
||||||
html_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one place `apps/api` talks to Forgejo — creating an issue from a
|
|
||||||
* contact message (`/admin/messages`, issue #81). A class + singleton
|
|
||||||
* (`forgejo` below), same convention as `logger` / `analytics`.
|
|
||||||
*
|
|
||||||
* **Fail-closed**: every method is a no-op / typed error unless all three
|
|
||||||
* `FORGEJO_*` env vars are set (see `config/env.ts`). Callers check
|
|
||||||
* {@link isConfigured} first and answer `503` themselves; {@link createIssue}
|
|
||||||
* re-checks as a backstop.
|
|
||||||
*/
|
|
||||||
export class ForgejoService {
|
|
||||||
/** True once `FORGEJO_BASE_URL`, `FORGEJO_TOKEN` and `FORGEJO_REPO` are all set. */
|
|
||||||
public isConfigured(): boolean {
|
|
||||||
return (
|
|
||||||
env.FORGEJO_BASE_URL !== undefined &&
|
|
||||||
env.FORGEJO_TOKEN !== undefined &&
|
|
||||||
env.FORGEJO_REPO !== undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an issue in `FORGEJO_REPO` with the given title/body (no labels
|
|
||||||
* — a deliberate #81 decision, the admin adds them in Forgejo). Returns
|
|
||||||
* the new issue's number and html URL.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` if the env isn't set,
|
|
||||||
* `502 FORGEJO_REQUEST_FAILED` on a network error or any non-2xx / non-JSON
|
|
||||||
* response — the caller leaves the database untouched in that case.
|
|
||||||
*/
|
|
||||||
public async createIssue(input: {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
}): Promise<{ number: number; htmlUrl: string }> {
|
|
||||||
if (!this.isConfigured()) {
|
|
||||||
throw new HttpError(
|
|
||||||
503,
|
|
||||||
ErrorCode.FORGEJO_NOT_CONFIGURED,
|
|
||||||
"Forgejo integration is not configured",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const url = `${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`;
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
// Forgejo/Gitea's PAT scheme — `token <pat>`, not `Bearer`.
|
|
||||||
Authorization: `token ${env.FORGEJO_TOKEN}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ title: input.title, body: input.body }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("Forgejo issue creation failed (network)", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
throw new HttpError(502, ErrorCode.FORGEJO_REQUEST_FAILED, "Forgejo request failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.text().catch(() => "");
|
|
||||||
logger.warn("Forgejo issue creation failed", {
|
|
||||||
status: response.status,
|
|
||||||
detail: detail.slice(0, 500),
|
|
||||||
});
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
ErrorCode.FORGEJO_REQUEST_FAILED,
|
|
||||||
`Forgejo issues API responded ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = (await response.json()) as ForgejoIssueResponse;
|
|
||||||
return { number: data.number, htmlUrl: data.html_url };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("Forgejo issue creation failed (bad response)", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
ErrorCode.FORGEJO_REQUEST_FAILED,
|
|
||||||
"Forgejo response was not valid JSON",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — stateless, same reasoning as `logger` / `analytics`. */
|
|
||||||
export const forgejo = new ForgejoService();
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
|
||||||
listPlaceholdersQuerySchema,
|
|
||||||
listTechStepPlaceholdersQuerySchema,
|
|
||||||
markPlaceholdersReviewedSchema,
|
|
||||||
markTechStepPlaceholdersReviewedSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,17 +7,12 @@ import {
|
||||||
markPlaceholdersReviewed,
|
markPlaceholdersReviewed,
|
||||||
pruneOrphanPlaceholders,
|
pruneOrphanPlaceholders,
|
||||||
} from "./admin-catalog.service.js";
|
} from "./admin-catalog.service.js";
|
||||||
import {
|
|
||||||
listTechStepPlaceholders,
|
|
||||||
markTechStepPlaceholdersReviewed,
|
|
||||||
} from "./admin-tech-step-catalog.service.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
||||||
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
|
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
||||||
* typed when the seeded catalog fell short — free-text ingredients on a
|
* "placeholders" users typed when the seeded catalog fell short, so a
|
||||||
* recipe line, and free-text techniques while correcting a step (issue #130)
|
* maintainer can see what's missing and mark gaps as handled.
|
||||||
* — so a maintainer can see what's missing and mark gaps as handled.
|
|
||||||
*/
|
*/
|
||||||
export const adminCatalogRouter = Router();
|
export const adminCatalogRouter = Router();
|
||||||
|
|
||||||
|
|
@ -52,29 +42,3 @@ adminCatalogRouter.post(
|
||||||
res.status(200).json(await pruneOrphanPlaceholders());
|
res.status(200).json(await pruneOrphanPlaceholders());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Missing techniques (issue #130) --------------------------------------
|
|
||||||
|
|
||||||
adminCatalogRouter.get(
|
|
||||||
"/tech-step-placeholders",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminCatalogRouter.patch(
|
|
||||||
"/tech-step-placeholders/mark-reviewed",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(
|
|
||||||
await markTechStepPlaceholdersReviewed(
|
|
||||||
markTechStepPlaceholdersReviewedSchema.parse(req.body),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import {
|
|
||||||
createForgejoIssueSchema,
|
|
||||||
ErrorCode,
|
|
||||||
listContactMessagesQuerySchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import {
|
|
||||||
createForgejoIssueFromMessage,
|
|
||||||
getForgejoStatus,
|
|
||||||
listContactMessages,
|
|
||||||
} from "./admin-contact.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind
|
|
||||||
* {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list
|
|
||||||
* of contact-form messages (issue #80), plus "create a Forgejo issue from
|
|
||||||
* this message" (issue #81). Still no message-mutation routes — the message
|
|
||||||
* itself stays read-only.
|
|
||||||
*/
|
|
||||||
export const adminContactRouter = Router();
|
|
||||||
|
|
||||||
adminContactRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Static path — declared before `/:id/...` so it's never parsed as an id.
|
|
||||||
adminContactRouter.get(
|
|
||||||
"/forgejo/status",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(getForgejoStatus());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminContactRouter.post(
|
|
||||||
"/:id/forgejo-issue",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a valid contact message id: ${req.params.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const input = createForgejoIssueSchema.parse(req.body);
|
|
||||||
res.status(200).json(await createForgejoIssueFromMessage(id, input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
type ContactMessageAdminView,
|
|
||||||
type ContactMessageType,
|
|
||||||
type CreateForgejoIssueInput,
|
|
||||||
ErrorCode,
|
|
||||||
type ForgejoIntegrationStatus,
|
|
||||||
type ListContactMessagesQuery,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { forgejo } from "../../lib/forgejo/forgejo.service.js";
|
|
||||||
|
|
||||||
/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */
|
|
||||||
const CONTACT_MESSAGES_PAGE_SIZE = 200;
|
|
||||||
|
|
||||||
const contactMessageInclude = {
|
|
||||||
author: { select: { id: true, firstName: true, lastName: true, email: true } },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ContactMessageRow = Awaited<
|
|
||||||
ReturnType<
|
|
||||||
typeof prisma.contactMessage.findFirstOrThrow<{ include: typeof contactMessageInclude }>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Shapes one Prisma `ContactMessage` row (with {@link contactMessageInclude}) into its admin view. */
|
|
||||||
function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminView {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
// Safe cast: `POST /contact` validates `type` against
|
|
||||||
// `CONTACT_MESSAGE_TYPES` before insert, so the column only ever holds
|
|
||||||
// one of those values.
|
|
||||||
type: row.type as ContactMessageType,
|
|
||||||
message: row.message,
|
|
||||||
pageUrl: row.pageUrl,
|
|
||||||
userAgent: row.userAgent,
|
|
||||||
appVersion: row.appVersion,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
// `null` when the sender's account was deleted after sending
|
|
||||||
// (`authorId` is `onDelete: SetNull`).
|
|
||||||
author: row.author,
|
|
||||||
forgejoIssueNumber: row.forgejoIssueNumber,
|
|
||||||
forgejoIssueUrl: row.forgejoIssueUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `ContactMessage` for the admin read view (`/admin/messages`, issue
|
|
||||||
* #80) — newest first, capped at {@link CONTACT_MESSAGES_PAGE_SIZE}, with an
|
|
||||||
* optional single-`type` filter. Read-only: this first version has no
|
|
||||||
* triage workflow (status / assignment / notes), so there's no update
|
|
||||||
* counterpart.
|
|
||||||
*/
|
|
||||||
export async function listContactMessages(
|
|
||||||
query: ListContactMessagesQuery,
|
|
||||||
): Promise<ContactMessageAdminView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.contactMessage.findMany({
|
|
||||||
where: query.type ? { type: query.type } : {},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: CONTACT_MESSAGES_PAGE_SIZE,
|
|
||||||
include: contactMessageInclude,
|
|
||||||
});
|
|
||||||
return rows.map(toContactMessageAdminView);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the "create a Forgejo issue from a message" feature is usable (issue #81) — drives whether the admin UI shows the button. */
|
|
||||||
export function getForgejoStatus(): ForgejoIntegrationStatus {
|
|
||||||
return { enabled: forgejo.isConfigured() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Forgejo issue from contact message `messageId` and records the
|
|
||||||
* link on the row (issue #81). The `title`/`body` come from the admin's
|
|
||||||
* pre-filled, edited modal.
|
|
||||||
*
|
|
||||||
* Order matters: every precondition is checked before Forgejo is called,
|
|
||||||
* and the row is only touched *after* Forgejo succeeds — a failed call
|
|
||||||
* leaves no partial state (no linked issue, button still shown).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` (env not set),
|
|
||||||
* `404 CONTACT_MESSAGE_NOT_FOUND`, `409 CONTACT_MESSAGE_ALREADY_LINKED`
|
|
||||||
* (already has an issue), or `502 FORGEJO_REQUEST_FAILED` (the Forgejo
|
|
||||||
* call failed — DB untouched).
|
|
||||||
*/
|
|
||||||
export async function createForgejoIssueFromMessage(
|
|
||||||
messageId: number,
|
|
||||||
input: CreateForgejoIssueInput,
|
|
||||||
): Promise<ContactMessageAdminView> {
|
|
||||||
try {
|
|
||||||
if (!forgejo.isConfigured()) {
|
|
||||||
throw new HttpError(
|
|
||||||
503,
|
|
||||||
ErrorCode.FORGEJO_NOT_CONFIGURED,
|
|
||||||
"Forgejo integration is not configured",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.contactMessage.findUnique({ where: { id: messageId } });
|
|
||||||
if (!existing) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.CONTACT_MESSAGE_NOT_FOUND,
|
|
||||||
`Contact message ${messageId} not found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (existing.forgejoIssueNumber !== null) {
|
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED,
|
|
||||||
`Contact message ${messageId} already links to Forgejo issue #${existing.forgejoIssueNumber}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throws `502 FORGEJO_REQUEST_FAILED` on any failure — propagated below,
|
|
||||||
// before the row is touched.
|
|
||||||
const issue = await forgejo.createIssue({ title: input.title, body: input.body });
|
|
||||||
|
|
||||||
const updated = await prisma.contactMessage.update({
|
|
||||||
where: { id: messageId },
|
|
||||||
data: { forgejoIssueNumber: issue.number, forgejoIssueUrl: issue.htmlUrl },
|
|
||||||
include: contactMessageInclude,
|
|
||||||
});
|
|
||||||
return toContactMessageAdminView(updated);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import type {
|
|
||||||
ListTechStepPlaceholdersQuery,
|
|
||||||
MarkTechStepPlaceholdersReviewedInput,
|
|
||||||
TechStepPlaceholderView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The missing-technique review (issue #130) — the tech-step counterpart of
|
|
||||||
* `admin-catalog.service.ts`'s off-catalog *ingredient* review. Kept a flat
|
|
||||||
* list rather than grouping near-duplicate spellings the way that one does:
|
|
||||||
* correcting a technique — let alone typing one the catalog is missing — is
|
|
||||||
* rare enough that a handful of raw rows is all a maintainer ever sees here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */
|
|
||||||
const placeholderInclude = {
|
|
||||||
steps: {
|
|
||||||
take: 50,
|
|
||||||
include: { step: { select: { recipe: { select: { id: true, name: true } } } } },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every placeholder `TechStep` (see `TechStep.isPlaceholder` in
|
|
||||||
* schema.prisma), most-impactful first (most distinct recipes, then name).
|
|
||||||
*
|
|
||||||
* `query.reviewed` selects which side to show: omitted / `"false"` drops
|
|
||||||
* rows already triaged (`reviewedAt` set) — the default working list;
|
|
||||||
* `"true"` keeps only those.
|
|
||||||
*/
|
|
||||||
export async function listTechStepPlaceholders(
|
|
||||||
query: ListTechStepPlaceholdersQuery,
|
|
||||||
): Promise<TechStepPlaceholderView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.techStep.findMany({
|
|
||||||
where: { isPlaceholder: true },
|
|
||||||
include: placeholderInclude,
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const wantReviewed = query.reviewed === "true";
|
|
||||||
return rows
|
|
||||||
.filter((row) => (row.reviewedAt !== null) === wantReviewed)
|
|
||||||
.map((row) => {
|
|
||||||
const recipeById = new Map<number, string>();
|
|
||||||
for (const link of row.steps) {
|
|
||||||
recipeById.set(link.step.recipe.id, link.step.recipe.name);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
displayName: row.displayName ?? "",
|
|
||||||
createdAt: row.createdAt?.toISOString() ?? null,
|
|
||||||
reviewedAt: row.reviewedAt?.toISOString() ?? null,
|
|
||||||
recipeCount: recipeById.size,
|
|
||||||
sampleRecipes: [...recipeById.entries()].slice(0, 5).map(([id, name]) => ({ id, name })),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
(a, b) => b.recipeCount - a.recipeCount || a.displayName.localeCompare(b.displayName, "fr"),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamps `reviewedAt` on the given placeholder `TechStep` ids — a
|
|
||||||
* maintainer has seen this proposal (and, if it warranted it, promoted it
|
|
||||||
* into `reference-seed-data.ts` + `tech-step-training-data.ts` by hand; this
|
|
||||||
* endpoint never touches the catalog itself). Scoped to
|
|
||||||
* `isPlaceholder: true` so a stray real id is a silent no-op. Returns how
|
|
||||||
* many rows were actually stamped.
|
|
||||||
*/
|
|
||||||
export async function markTechStepPlaceholdersReviewed(
|
|
||||||
input: MarkTechStepPlaceholdersReviewedInput,
|
|
||||||
): Promise<{ reviewed: number }> {
|
|
||||||
try {
|
|
||||||
const { count } = await prisma.techStep.updateMany({
|
|
||||||
where: { id: { in: input.techStepIds }, isPlaceholder: true },
|
|
||||||
data: { reviewedAt: new Date() },
|
|
||||||
});
|
|
||||||
return { reviewed: count };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { adminCreateUserSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import { createUserProfile, listUsers } from "./admin-users.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/admin/api/users` (via `admin.routes.ts`) — every route
|
|
||||||
* behind {@link requireAdmin}. Backs the `/admin/utilisateurs` page: a
|
|
||||||
* read-only list of end-user accounts and a "create an account by hand"
|
|
||||||
* form. The server generates a one-time temporary password on create and
|
|
||||||
* returns it in the response (see `admin-users.service.ts`).
|
|
||||||
*/
|
|
||||||
export const adminUsersRouter = Router();
|
|
||||||
|
|
||||||
adminUsersRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await listUsers());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminUsersRouter.post(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = adminCreateUserSchema.parse(req.body);
|
|
||||||
res.status(201).json(await createUserProfile(input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import { randomInt } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
type AdminCreateUserInput,
|
|
||||||
type AdminUserCreatedView,
|
|
||||||
type AdminUserListItemView,
|
|
||||||
ErrorCode,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import argon2 from "argon2";
|
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many accounts `listUsers` returns per call. Like `CONTACT_MESSAGES_PAGE_SIZE`
|
|
||||||
* this is a triage view, not an export — the admin creates/looks up a handful
|
|
||||||
* of accounts, it never needs the whole table paginated.
|
|
||||||
*/
|
|
||||||
const USERS_PAGE_SIZE = 200;
|
|
||||||
|
|
||||||
// Same reasoning as `admin-auth.service.ts`'s / `auth.service.ts`'s `hashOptions`:
|
|
||||||
// argon2's real defaults are deliberately expensive; the test suite hashes
|
|
||||||
// against throwaway data many times per run, so a cheaper cost keeps it fast
|
|
||||||
// without weakening anything real. Never applies outside NODE_ENV=test.
|
|
||||||
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
|
||||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Charset for {@link generateTemporaryPassword} — upper/lower letters + digits,
|
|
||||||
* minus the visually ambiguous ones (`0`/`O`/`1`/`l`/`I`), same spirit as
|
|
||||||
* `house.service.ts`'s `INVITE_CODE_CHARS`: this string is read off one screen
|
|
||||||
* and typed into another (or pasted, but not always).
|
|
||||||
*/
|
|
||||||
const TEMP_PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
|
||||||
|
|
||||||
/** Length of a generated temporary password — comfortably above `signupSchema`'s 8-char floor. */
|
|
||||||
const TEMP_PASSWORD_LENGTH = 16;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fresh random temporary password for an admin-created account. Uses
|
|
||||||
* `node:crypto`'s `randomInt` (not `Math.random`) exactly like
|
|
||||||
* `generateInviteCode()` — this value guards a real account until its owner
|
|
||||||
* changes it. Returned to the operator once and never stored in plain text.
|
|
||||||
*/
|
|
||||||
function generateTemporaryPassword(): string {
|
|
||||||
let password = "";
|
|
||||||
for (let i = 0; i < TEMP_PASSWORD_LENGTH; i++) {
|
|
||||||
password += TEMP_PASSWORD_CHARS[randomInt(TEMP_PASSWORD_CHARS.length)];
|
|
||||||
}
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `UserProfile` row with its household name joined in — the shape {@link toListItem} maps from. */
|
|
||||||
type UserRow = Awaited<
|
|
||||||
ReturnType<
|
|
||||||
typeof prisma.userProfile.findFirstOrThrow<{
|
|
||||||
include: { house: { select: { name: true } } };
|
|
||||||
}>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Shapes one Prisma `UserProfile` row (with its `house` name) into the admin list view — drops `passwordHash`/`tokenVersion`, resolves the household name. */
|
|
||||||
function toListItem(row: UserRow): AdminUserListItemView {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
firstName: row.firstName,
|
|
||||||
lastName: row.lastName,
|
|
||||||
email: row.email,
|
|
||||||
householdName: row.house?.name ?? null,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every end-user account for the admin `/admin/utilisateurs` list — newest
|
|
||||||
* first, capped at {@link USERS_PAGE_SIZE}. Read-only: this first version has
|
|
||||||
* no edit/disable/delete (deleting an account is irreversible and stays a
|
|
||||||
* deliberate self-service action in `AccountSettingsPage`).
|
|
||||||
*/
|
|
||||||
export async function listUsers(): Promise<AdminUserListItemView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.userProfile.findMany({
|
|
||||||
// `id` as a tiebreaker so the order is total even if two accounts share
|
|
||||||
// a `createdAt` (bulk-provisioned in the same tick) — `id` ascends with
|
|
||||||
// `createdAt` anyway.
|
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
||||||
take: USERS_PAGE_SIZE,
|
|
||||||
include: { house: { select: { name: true } } },
|
|
||||||
});
|
|
||||||
return rows.map(toListItem);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
|
||||||
// this layer just isn't allowed a bare `await`. Same as `admin-contact.service.ts`.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provisions a new end-user account by hand and returns it together with the
|
|
||||||
* one-time temporary password the caller must relay to the user. Mirrors
|
|
||||||
* `auth.service.ts`'s `signup` for the parts that matter (email-uniqueness
|
|
||||||
* check, argon2 hash, `UserProfile` row with `houseId`/`dietId` left `null`)
|
|
||||||
* but issues **no session token** — the operator isn't logging in as this
|
|
||||||
* user — and generates the password instead of accepting one.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
|
||||||
*/
|
|
||||||
export async function createUserProfile(
|
|
||||||
input: AdminCreateUserInput,
|
|
||||||
): Promise<AdminUserCreatedView> {
|
|
||||||
try {
|
|
||||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
|
||||||
if (existing) {
|
|
||||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
|
||||||
}
|
|
||||||
|
|
||||||
const temporaryPassword = generateTemporaryPassword();
|
|
||||||
const passwordHash = await argon2.hash(temporaryPassword, hashOptions);
|
|
||||||
|
|
||||||
const profile = await prisma.userProfile.create({
|
|
||||||
// No household/diet here, same as `signup` — the user picks those in the
|
|
||||||
// onboarding wizard after their first login.
|
|
||||||
data: {
|
|
||||||
firstName: input.firstName,
|
|
||||||
lastName: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
passwordHash,
|
|
||||||
},
|
|
||||||
include: { house: { select: { name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire-and-forget, one line, no migration (see `analytics.service.ts`). A
|
|
||||||
// distinct type from `user.signup` so the dashboard can tell hand-provisioned
|
|
||||||
// accounts apart; `actorType: "system"` because the actor is an operator, not
|
|
||||||
// the new user (and `AnalyticsEvent.actorId` only ever means a `UserProfile.id`).
|
|
||||||
analytics.recordEvent("admin.user_created", {
|
|
||||||
actorType: "system",
|
|
||||||
context: { userId: profile.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user: toListItem(profile), temporaryPassword };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see listUsers()'s catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||||
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
||||||
import { adminContactRouter } from "./admin-contact.routes.js";
|
|
||||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||||
import { adminToursRouter } from "./admin-tours.routes.js";
|
import { adminToursRouter } from "./admin-tours.routes.js";
|
||||||
import { adminUsersRouter } from "./admin-users.routes.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregator for the admin application's API surface, mounted at
|
* Aggregator for the admin application's API surface, mounted at
|
||||||
|
|
@ -28,5 +26,3 @@ adminRouter.use("/metrics", adminMetricsRouter);
|
||||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
adminRouter.use("/catalog", adminCatalogRouter);
|
||||||
adminRouter.use("/tours", adminToursRouter);
|
adminRouter.use("/tours", adminToursRouter);
|
||||||
adminRouter.use("/contact", adminContactRouter);
|
|
||||||
adminRouter.use("/users", adminUsersRouter);
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { submitContactMessageSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
|
||||||
import { submitContactMessage } from "./contact.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/contact` in app.ts (issue #80). A single endpoint —
|
|
||||||
* `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a
|
|
||||||
* session: only signed-in users can send a message, so every report is
|
|
||||||
* attributable (see `ContactMessage.authorId`). The admin-side read of
|
|
||||||
* these rows lives entirely in the admin router, not here.
|
|
||||||
*/
|
|
||||||
export const contactRouter = Router();
|
|
||||||
|
|
||||||
contactRouter.post(
|
|
||||||
"/",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = submitContactMessageSchema.parse(req.body);
|
|
||||||
// Read the UA from the request, never from the body — the schema omits
|
|
||||||
// it on purpose so it can't be spoofed. Header can be an array or
|
|
||||||
// absent; normalize to `string | null`.
|
|
||||||
const rawUserAgent = req.headers["user-agent"];
|
|
||||||
const userAgent =
|
|
||||||
typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null;
|
|
||||||
await submitContactMessage(res.locals.userProfile.id, input, userAgent);
|
|
||||||
// 204, not 201-with-a-body: the form has nothing to do with the stored
|
|
||||||
// row, and the shared `ApiClient._request` treats only 204 as bodyless
|
|
||||||
// (a 201 with an empty body makes its `response.json()` throw). Same
|
|
||||||
// "mutation returns nothing" shape as `POST /auth/logout`.
|
|
||||||
res.status(204).end();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import type { SubmitContactMessageInput } from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists one contact-form submission (issue #80). Write-only from the
|
|
||||||
* user's side — there is no read/update/delete counterpart here: admins
|
|
||||||
* consult these rows through the admin app (`admin-contact.service.ts`, PR
|
|
||||||
* 2), never this module.
|
|
||||||
*
|
|
||||||
* `userAgent` comes straight from the request header, never the body — the
|
|
||||||
* shared `submitContactMessageSchema` deliberately omits it so the client
|
|
||||||
* can't spoof it. `pageUrl` / `appVersion` are client-supplied context and
|
|
||||||
* default to `null` when the (older, or non-web) client didn't send them.
|
|
||||||
*
|
|
||||||
* Returns nothing: the form only needs to know the write succeeded (a
|
|
||||||
* `201`), not the stored row.
|
|
||||||
*/
|
|
||||||
export async function submitContactMessage(
|
|
||||||
authorId: number,
|
|
||||||
input: SubmitContactMessageInput,
|
|
||||||
userAgent: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await prisma.contactMessage.create({
|
|
||||||
data: {
|
|
||||||
authorId,
|
|
||||||
type: input.type,
|
|
||||||
message: input.message,
|
|
||||||
pageUrl: input.pageUrl ?? null,
|
|
||||||
userAgent,
|
|
||||||
appVersion: input.appVersion ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Fire-and-forget (see AnalyticsService's contract) — `admin-metrics`
|
|
||||||
// aggregates `AnalyticsEvent` by `type` generically, so this shows up in
|
|
||||||
// the dashboard series with no extra code; `context.type` lets it be
|
|
||||||
// broken down by bug/suggestion/other.
|
|
||||||
analytics.recordEvent("contact.submitted", {
|
|
||||||
actorType: "user",
|
|
||||||
actorId: authorId,
|
|
||||||
context: { type: input.type },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler` / the error middleware (which
|
|
||||||
// already logs it) handles it; this service layer just isn't allowed a
|
|
||||||
// bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -89,12 +89,7 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
||||||
unit: line.unit === null ? null : toUnitView(line.unit),
|
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
techStep: {
|
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
||||||
id: techStep.techStep.id,
|
|
||||||
key: techStep.techStep.key,
|
|
||||||
isPlaceholder: techStep.techStep.isPlaceholder,
|
|
||||||
displayName: techStep.techStep.displayName,
|
|
||||||
},
|
|
||||||
order: techStep.order,
|
order: techStep.order,
|
||||||
ingredients,
|
ingredients,
|
||||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/mobile` in app.ts. Both routes are deliberately
|
|
||||||
* public (no `requireAuth`): the landing page's download link is shown to
|
|
||||||
* logged-out visitors, and the native update check (issue #66) runs on
|
|
||||||
* every app start, session or not. Both exist purely to work around
|
|
||||||
* Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s
|
|
||||||
* own doc comment for the full story.
|
|
||||||
*/
|
|
||||||
export const mobileRouter = Router();
|
|
||||||
|
|
||||||
mobileRouter.get(
|
|
||||||
"/latest-release",
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json({ version: await getLatestVersion() });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A plain redirect, not JSON — lets any caller (the landing page's `<a>`,
|
|
||||||
// the update banner's "Mettre à jour" link) use a static href with zero
|
|
||||||
// client-side fetch, and always resolves to something useful even if
|
|
||||||
// Forgejo is briefly unreachable (see getLatestApkUrl's fallback).
|
|
||||||
mobileRouter.get(
|
|
||||||
"/download",
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.redirect(302, await getLatestApkUrl());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgejo's own Releases API for this repo — public, unauthenticated (the
|
|
||||||
* same fact `local-verification-while-ci-disabled` already leans on for
|
|
||||||
* CI-status checks without a token). **Must be called server-side, not
|
|
||||||
* from the browser**: verified live that Forgejo sends no
|
|
||||||
* `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from
|
|
||||||
* `apps/web` fails with an opaque CORS error even though the endpoint
|
|
||||||
* itself is perfectly reachable — this whole module exists to proxy it
|
|
||||||
* from a context CORS doesn't apply to.
|
|
||||||
*/
|
|
||||||
const LATEST_RELEASE_API_URL =
|
|
||||||
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl}
|
|
||||||
* when no matching asset is found (a release with nothing attached yet,
|
|
||||||
* Forgejo briefly unreachable, ...). Never a dead link, just a slightly
|
|
||||||
* less direct one: the visitor lands on the release itself and can find
|
|
||||||
* the asset from there.
|
|
||||||
*/
|
|
||||||
const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest";
|
|
||||||
|
|
||||||
/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */
|
|
||||||
const APK_ASSET_NAME = "batchcooking.apk";
|
|
||||||
|
|
||||||
interface ForgejoReleaseAsset {
|
|
||||||
name: string;
|
|
||||||
browser_download_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ForgejoRelease {
|
|
||||||
tag_name: string;
|
|
||||||
assets: ForgejoReleaseAsset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLatestRelease(): Promise<ForgejoRelease> {
|
|
||||||
const response = await fetch(LATEST_RELEASE_API_URL);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
ErrorCode.INTERNAL_ERROR,
|
|
||||||
`Forgejo releases API responded ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await response.json()) as ForgejoRelease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the
|
|
||||||
* native app's update check (issue #66, `useAppUpdateCheck.ts`).
|
|
||||||
*/
|
|
||||||
export async function getLatestVersion(): Promise<string> {
|
|
||||||
const release = await fetchLatestRelease();
|
|
||||||
return release.tag_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a "download the app" click should end up right now: the actual
|
|
||||||
* signed-APK asset of the latest release if one is attached, else
|
|
||||||
* {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a
|
|
||||||
* download link must always go *somewhere* useful, even when Forgejo is
|
|
||||||
* briefly unreachable.
|
|
||||||
*/
|
|
||||||
export async function getLatestApkUrl(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const release = await fetchLatestRelease();
|
|
||||||
const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME);
|
|
||||||
return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL;
|
|
||||||
} catch {
|
|
||||||
return LATEST_RELEASE_PAGE_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -381,20 +380,10 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
||||||
start: correction.start,
|
start: correction.start,
|
||||||
end: correction.end,
|
end: correction.end,
|
||||||
previousTechStep: correction.previousTechStep
|
previousTechStep: correction.previousTechStep
|
||||||
? {
|
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
||||||
id: correction.previousTechStep.id,
|
|
||||||
key: correction.previousTechStep.key,
|
|
||||||
isPlaceholder: correction.previousTechStep.isPlaceholder,
|
|
||||||
displayName: correction.previousTechStep.displayName,
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
correctedTechStep: correction.correctedTechStep
|
correctedTechStep: correction.correctedTechStep
|
||||||
? {
|
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
||||||
id: correction.correctedTechStep.id,
|
|
||||||
key: correction.correctedTechStep.key,
|
|
||||||
isPlaceholder: correction.correctedTechStep.isPlaceholder,
|
|
||||||
displayName: correction.correctedTechStep.displayName,
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
createdAt: correction.createdAt.toISOString(),
|
createdAt: correction.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -460,32 +449,7 @@ export async function submitTechStepCorrection(
|
||||||
);
|
);
|
||||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
||||||
|
|
||||||
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction(
|
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
||||||
async (tx) => {
|
|
||||||
// A technique the catalog doesn't have (issue #130) — mint a
|
|
||||||
// placeholder `TechStep` for it, in the same transaction as the
|
|
||||||
// correction that references it, then proceed exactly as if its id had
|
|
||||||
// been sent as `correctedTechStepId`. Same shape as
|
|
||||||
// `recipe.service.ts`'s `resolveIngredientLines` does for a
|
|
||||||
// free-text ingredient.
|
|
||||||
const placeholderTechStepId =
|
|
||||||
input.correctedTechStepName !== undefined
|
|
||||||
? (
|
|
||||||
await tx.techStep.create({
|
|
||||||
data: {
|
|
||||||
key: `placeholder:${randomUUID()}`,
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: input.correctedTechStepName,
|
|
||||||
createdById: correctorId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
).id
|
|
||||||
: null;
|
|
||||||
const effectiveCorrectedTechStepId =
|
|
||||||
placeholderTechStepId ?? input.correctedTechStepId ?? null;
|
|
||||||
|
|
||||||
const createdCorrection = await tx.stepTechStepCorrection.create({
|
const createdCorrection = await tx.stepTechStepCorrection.create({
|
||||||
data: {
|
data: {
|
||||||
stepId: step.id,
|
stepId: step.id,
|
||||||
|
|
@ -493,7 +457,7 @@ export async function submitTechStepCorrection(
|
||||||
start: input.start,
|
start: input.start,
|
||||||
end: input.end,
|
end: input.end,
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
correctedTechStepId: effectiveCorrectedTechStepId,
|
correctedTechStepId: input.correctedTechStepId ?? null,
|
||||||
},
|
},
|
||||||
include: correctionInclude,
|
include: correctionInclude,
|
||||||
});
|
});
|
||||||
|
|
@ -503,7 +467,7 @@ export async function submitTechStepCorrection(
|
||||||
step.id,
|
step.id,
|
||||||
{ start: input.start, end: input.end },
|
{ start: input.start, end: input.end },
|
||||||
input.previousTechStepId ?? null,
|
input.previousTechStepId ?? null,
|
||||||
effectiveCorrectedTechStepId,
|
input.correctedTechStepId ?? null,
|
||||||
input.ingredients === undefined &&
|
input.ingredients === undefined &&
|
||||||
input.utensils === undefined &&
|
input.utensils === undefined &&
|
||||||
input.temperatures === undefined
|
input.temperatures === undefined
|
||||||
|
|
@ -560,9 +524,8 @@ export async function submitTechStepCorrection(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId };
|
return { correction: createdCorrection, techSteps: freshTechSteps };
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
analytics.recordEvent("tech_step.correction_submitted", {
|
analytics.recordEvent("tech_step.correction_submitted", {
|
||||||
actorId: correctorId,
|
actorId: correctorId,
|
||||||
|
|
@ -570,20 +533,9 @@ export async function submitTechStepCorrection(
|
||||||
recipeId,
|
recipeId,
|
||||||
stepId,
|
stepId,
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
correctedTechStepId: correction.correctedTechStepId,
|
correctedTechStepId: input.correctedTechStepId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (placeholderTechStepId !== null) {
|
|
||||||
analytics.recordEvent("tech_step.placeholder_proposed", {
|
|
||||||
actorId: correctorId,
|
|
||||||
context: {
|
|
||||||
recipeId,
|
|
||||||
stepId,
|
|
||||||
techStepId: placeholderTechStepId,
|
|
||||||
name: input.correctedTechStepName ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
computeRecipeDietViews,
|
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
|
|
@ -30,7 +29,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
|
||||||
// is a parameter rather than baked into that module.
|
// is a parameter rather than baked into that module.
|
||||||
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
||||||
|
|
||||||
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */
|
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
||||||
function recipeInclude(viewerId: number) {
|
function recipeInclude(viewerId: number) {
|
||||||
return {
|
return {
|
||||||
ingredients: {
|
ingredients: {
|
||||||
|
|
@ -75,38 +74,18 @@ function recipeInclude(viewerId: number) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
diets: { include: { diet: true } },
|
||||||
favoritedBy: { where: { userProfileId: viewerId } },
|
favoritedBy: { where: { userProfileId: viewerId } },
|
||||||
} satisfies Prisma.RecipeInclude;
|
} satisfies Prisma.RecipeInclude;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Every reference `Diet` row (`{ id, key }`), loaded once and memoized —
|
|
||||||
* `Diet` is seeded reference data that never changes at runtime, so caching
|
|
||||||
* it for the process's lifetime is safe. Used to attach a real `DietView` to
|
|
||||||
* the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to
|
|
||||||
* resolve the planning picker's `dietIds` filter / a household's members'
|
|
||||||
* regimes for the post-filter in {@link listRecipes}.
|
|
||||||
*/
|
|
||||||
let referenceDietsPromise: Promise<DietView[]> | null = null;
|
|
||||||
function loadReferenceDiets(): Promise<DietView[]> {
|
|
||||||
if (referenceDietsPromise === null) {
|
|
||||||
referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } });
|
|
||||||
}
|
|
||||||
return referenceDietsPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
|
|
||||||
async function loadGlutenFreeDiet(): Promise<DietView | undefined> {
|
|
||||||
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||||
include: ReturnType<typeof recipeInclude>;
|
include: ReturnType<typeof recipeInclude>;
|
||||||
}>;
|
}>;
|
||||||
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
||||||
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */
|
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
|
||||||
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
|
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||||
|
|
||||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
||||||
export function toUnitView(unit: UnitWithDetails): UnitView {
|
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
|
|
@ -142,6 +121,10 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDietView(diet: { id: number; key: string }): DietView {
|
||||||
|
return { id: diet.id, key: diet.key };
|
||||||
|
}
|
||||||
|
|
||||||
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
||||||
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||||
const byId = new Map<number, AllergyView>();
|
const byId = new Map<number, AllergyView>();
|
||||||
|
|
@ -153,13 +136,10 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||||
return [...byId.values()];
|
return [...byId.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */
|
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
|
||||||
function toRecipeSummaryView(
|
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||||
recipe: RecipeWithDetails,
|
const allergens = aggregateAllergens(
|
||||||
glutenFreeDiet: DietView | undefined,
|
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
|
||||||
): RecipeSummaryView {
|
|
||||||
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
|
|
||||||
toIngredientView(recipeIngredient.ingredient),
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: recipe.id,
|
id: recipe.id,
|
||||||
|
|
@ -169,9 +149,8 @@ function toRecipeSummaryView(
|
||||||
portions: recipe.portions,
|
portions: recipe.portions,
|
||||||
authorId: recipe.authorId,
|
authorId: recipe.authorId,
|
||||||
visibility: recipe.visibility,
|
visibility: recipe.visibility,
|
||||||
allergens: aggregateAllergens(ingredientViews),
|
allergens,
|
||||||
// Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
|
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
||||||
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
|
|
||||||
isFavorite: recipe.favoritedBy.length > 0,
|
isFavorite: recipe.favoritedBy.length > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -211,12 +190,7 @@ export function toStepTechStepViews(
|
||||||
} = stepTechStep;
|
} = stepTechStep;
|
||||||
if (start === null || end === null) continue;
|
if (start === null || end === null) continue;
|
||||||
views.push({
|
views.push({
|
||||||
techStep: {
|
techStep: { id: techStep.id, key: techStep.key },
|
||||||
id: techStep.id,
|
|
||||||
key: techStep.key,
|
|
||||||
isPlaceholder: techStep.isPlaceholder,
|
|
||||||
displayName: techStep.displayName,
|
|
||||||
},
|
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
// `source` is a plain DB `String`, not a Prisma enum (see
|
// `source` is a plain DB `String`, not a Prisma enum (see
|
||||||
|
|
@ -266,16 +240,15 @@ export function toStepTechStepViews(
|
||||||
return views;
|
return views;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
|
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
|
||||||
function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
|
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||||
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
|
quantity: Number(recipeIngredient.quantity),
|
||||||
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
|
unit: toUnitView(recipeIngredient.unit),
|
||||||
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
...toRecipeSummaryView(recipe, glutenFreeDiet),
|
...toRecipeSummaryView(recipe),
|
||||||
ingredients,
|
ingredients,
|
||||||
steps: recipe.steps.map((step) => ({
|
steps: recipe.steps.map((step) => ({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
|
|
@ -328,57 +301,54 @@ function visibleToViewerWhere(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two halves of the planning recipe picker's "convient à tout le foyer"
|
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
|
||||||
* toggle (`suitableForHousehold` on `listRecipes`):
|
* and, for every member with a declared regime, are tagged with that
|
||||||
* - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
|
* regime — the planning recipe picker's "convient à tout le foyer" toggle
|
||||||
* contains an allergen declared by a member (still expressible in SQL).
|
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
|
||||||
* - `requiredDietKeys` — every regime declared by a member (`omnivore`
|
* extra query to gather the household's members' allergy/regime ids)
|
||||||
* dropped: it constrains nothing). A recipe must satisfy *all* of them —
|
* rather than exposed to the client as raw per-member data: a member's
|
||||||
* a recipe suiting a vegetarian member doesn't automatically suit a
|
* allergies/regime are private the same way visibility already keeps a
|
||||||
* gluten-free one. Applied as a post-filter in {@link listRecipes} on the
|
* recipe's existence private (404, never 403) — nothing here should let
|
||||||
* *computed* `RecipeSummaryView.diets`, since regimes are no longer a
|
* one member infer another's medical/dietary info from the shape of a
|
||||||
* stored column to query (issue #131).
|
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
|
||||||
*
|
* the schema already treats disliked ingredients as a taste preference,
|
||||||
* Computed server-side (a small extra query for the members' allergy/regime
|
* not a safety constraint (see that model's doc comment), so it doesn't
|
||||||
* data) rather than exposed to the client as raw per-member data: a
|
* belong in a filter framed around what's safe/appropriate to serve.
|
||||||
* member's allergies/regime are private the same way visibility already
|
|
||||||
* keeps a recipe's existence private. Deliberately excludes
|
|
||||||
* `UserProfileDislikedIngredient` — a taste preference, not a safety
|
|
||||||
* constraint (see that model's doc comment).
|
|
||||||
*/
|
*/
|
||||||
async function suitableForHousehold(
|
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
||||||
houseId: number,
|
|
||||||
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
|
|
||||||
try {
|
try {
|
||||||
const members = await prisma.userProfile.findMany({
|
const members = await prisma.userProfile.findMany({
|
||||||
where: { houseId },
|
where: { houseId },
|
||||||
select: {
|
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
||||||
diet: { select: { key: true } },
|
|
||||||
allergies: { select: { allergyId: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const requiredDietKeys = [
|
const requiredDietIds = [
|
||||||
...new Set(
|
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
|
||||||
members
|
|
||||||
.map((member) => member.diet?.key)
|
|
||||||
.filter((key): key is string => key !== undefined && key !== "omnivore"),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
const excludedAllergyIds = [
|
const excludedAllergyIds = [
|
||||||
...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))),
|
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
|
||||||
];
|
];
|
||||||
|
|
||||||
const allergenWhere: Prisma.RecipeWhereInput =
|
const conditions: Prisma.RecipeWhereInput[] = [];
|
||||||
excludedAllergyIds.length > 0
|
if (requiredDietIds.length > 0) {
|
||||||
? {
|
// Every diet declared by a member must be among this recipe's tags —
|
||||||
|
// not "at least one", since a recipe suiting a vegetarian member
|
||||||
|
// doesn't automatically suit a gluten-free one too.
|
||||||
|
conditions.push({
|
||||||
|
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (excludedAllergyIds.length > 0) {
|
||||||
|
conditions.push({
|
||||||
ingredients: {
|
ingredients: {
|
||||||
none: {
|
none: {
|
||||||
ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
|
ingredient: {
|
||||||
|
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
: {};
|
return { AND: conditions };
|
||||||
return { allergenWhere, requiredDietKeys };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||||
|
|
@ -412,7 +382,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
|
||||||
).map((row) => row.sourceId);
|
).map((row) => row.sourceId);
|
||||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,11 +396,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
|
||||||
export interface ListRecipesFilters {
|
export interface ListRecipesFilters {
|
||||||
/** Case-insensitive name substring. */
|
/** Case-insensitive name substring. */
|
||||||
search?: string;
|
search?: string;
|
||||||
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
|
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
|
||||||
suitableForHousehold?: boolean;
|
suitableForHousehold?: boolean;
|
||||||
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
|
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
|
||||||
ingredientIds?: number[];
|
ingredientIds?: number[];
|
||||||
/** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */
|
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
|
||||||
dietIds?: number[];
|
dietIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,32 +420,31 @@ export async function listRecipes(
|
||||||
filters: ListRecipesFilters = {},
|
filters: ListRecipesFilters = {},
|
||||||
): Promise<RecipeSummaryView[]> {
|
): Promise<RecipeSummaryView[]> {
|
||||||
try {
|
try {
|
||||||
const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
|
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||||
}
|
}
|
||||||
// Regimes are computed from ingredients, not a stored column (issue
|
|
||||||
// #131), so the household-regime and `dietIds` filters can't be SQL
|
|
||||||
// anymore — they run as post-filters on the mapped summaries below.
|
|
||||||
// Everything that still *can* be a `where` stays one.
|
|
||||||
let requiredDietKeys: string[] = [];
|
|
||||||
// No-op without a household — nothing to filter against, same posture as
|
// No-op without a household — nothing to filter against, same posture as
|
||||||
// the `foyer` tab returning everything it can rather than throwing.
|
// the `foyer` tab returning everything it can rather than throwing.
|
||||||
if (suitable && viewerHouseId !== null) {
|
if (suitableForHousehold && viewerHouseId !== null) {
|
||||||
const household = await suitableForHousehold(viewerHouseId);
|
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
|
||||||
conditions.push(household.allergenWhere);
|
|
||||||
requiredDietKeys = household.requiredDietKeys;
|
|
||||||
}
|
}
|
||||||
if (ingredientIds && ingredientIds.length > 0) {
|
if (ingredientIds && ingredientIds.length > 0) {
|
||||||
// One condition per required id (AND) — a recipe must carry all of
|
// One condition per required id (AND) — a recipe must carry all of
|
||||||
// them, not just one.
|
// them, not just one, same "every one, not any one" posture as
|
||||||
|
// suitableForHouseholdWhere's requiredDietIds.
|
||||||
conditions.push({
|
conditions.push({
|
||||||
AND: ingredientIds.map((ingredientId) => ({
|
AND: ingredientIds.map((ingredientId) => ({
|
||||||
ingredients: { some: { ingredientId } },
|
ingredients: { some: { ingredientId } },
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (dietIds && dietIds.length > 0) {
|
||||||
|
conditions.push({
|
||||||
|
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "favoris":
|
case "favoris":
|
||||||
|
|
@ -495,38 +464,14 @@ export async function listRecipes(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipes, referenceDiets] = await Promise.all([
|
const recipes = await prisma.recipe.findMany({
|
||||||
prisma.recipe.findMany({
|
|
||||||
where: { AND: conditions },
|
where: { AND: conditions },
|
||||||
include: recipeInclude(viewerId),
|
include: recipeInclude(viewerId),
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
});
|
||||||
loadReferenceDiets(),
|
return recipes.map(toRecipeSummaryView);
|
||||||
]);
|
|
||||||
const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree");
|
|
||||||
|
|
||||||
let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet));
|
|
||||||
|
|
||||||
// Post-filters on the *computed* `diets` (see the comment where the SQL
|
|
||||||
// conditions are built). "Every one, not any one" for both.
|
|
||||||
if (requiredDietKeys.length > 0) {
|
|
||||||
summaries = summaries.filter((summary) =>
|
|
||||||
requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dietIds && dietIds.length > 0) {
|
|
||||||
// `omnivore` in the filter would match nothing (it's never emitted) —
|
|
||||||
// treat it as "no constraint" rather than silently returning an empty list.
|
|
||||||
const requiredIds = dietIds.filter(
|
|
||||||
(id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore",
|
|
||||||
);
|
|
||||||
summaries = summaries.filter((summary) =>
|
|
||||||
requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return summaries;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,23 +490,23 @@ export async function getRecipe(
|
||||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
}
|
}
|
||||||
return toRecipeView(recipe, await loadGlutenFreeDiet());
|
return toRecipeView(recipe);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a recipe with its ingredients and ordered steps in one go —
|
* Creates a recipe with its ingredients, ordered steps and diet tags in one
|
||||||
* steps' `order` is derived from their position in `input.steps`,
|
* go — steps' `order` is derived from their position in `input.steps`,
|
||||||
* ingredients reference existing reference `Ingredient` rows by id (see
|
* ingredients reference existing reference `Ingredient` rows by id (see
|
||||||
* `GET /reference/ingredients`; there's no way to create one here). Its
|
* `GET /reference/ingredients`; there's no way to create one here).
|
||||||
* regimes aren't stored — they're computed from the ingredients on read
|
* `authorId`/`authorHouseId` are fixed at creation and never change on
|
||||||
* (issue #131). `authorId`/`authorHouseId` are fixed at creation and never
|
* later edits (see {@link updateRecipe}).
|
||||||
* change on later edits (see {@link updateRecipe}).
|
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
*/
|
*/
|
||||||
export async function createRecipe(
|
export async function createRecipe(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
|
|
@ -571,7 +516,7 @@ export async function createRecipe(
|
||||||
try {
|
try {
|
||||||
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -588,6 +533,7 @@ export async function createRecipe(
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
*/
|
*/
|
||||||
export async function createImportedRecipe(
|
export async function createImportedRecipe(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
|
|
@ -598,7 +544,7 @@ export async function createImportedRecipe(
|
||||||
try {
|
try {
|
||||||
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
return await createRecipeInternal(input, authorId, authorHouseId, source);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,11 +578,11 @@ async function matchStepsTechSteps<T extends { description: string }>(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */
|
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
|
||||||
interface ResolvedIngredientLine {
|
interface ResolvedIngredientLine {
|
||||||
ingredientId: number;
|
ingredientId: number;
|
||||||
quantity: number | null;
|
quantity: number;
|
||||||
unitId: number | null;
|
unitId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -669,8 +615,8 @@ async function resolveIngredientLines(
|
||||||
if (line.ingredientId !== undefined) {
|
if (line.ingredientId !== undefined) {
|
||||||
resolved.push({
|
resolved.push({
|
||||||
ingredientId: line.ingredientId,
|
ingredientId: line.ingredientId,
|
||||||
quantity: line.quantity ?? null,
|
quantity: line.quantity,
|
||||||
unitId: line.unitId ?? null,
|
unitId: line.unitId,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -686,16 +632,12 @@ async function resolveIngredientLines(
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
resolved.push({
|
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
|
||||||
ingredientId: placeholder.id,
|
|
||||||
quantity: line.quantity ?? null,
|
|
||||||
unitId: line.unitId ?? null,
|
|
||||||
});
|
|
||||||
createdPlaceholders.push({ id: placeholder.id, name });
|
createdPlaceholders.push({ id: placeholder.id, name });
|
||||||
}
|
}
|
||||||
return { resolved, createdPlaceholders };
|
return { resolved, createdPlaceholders };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -709,9 +651,8 @@ async function createRecipeInternal(
|
||||||
await assertIngredientsExist(
|
await assertIngredientsExist(
|
||||||
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
||||||
);
|
);
|
||||||
await assertUnitsExist(
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
await assertDietsExist(input.dietIds);
|
||||||
);
|
|
||||||
// Matched up front (one call per step, in parallel) rather than inline
|
// Matched up front (one call per step, in parallel) rather than inline
|
||||||
// inside the `steps.create` map below — `techStepClassifier` is async
|
// inside the `steps.create` map below — `techStepClassifier` is async
|
||||||
// (a trained model, not a pure regex test), so its result has to
|
// (a trained model, not a pure regex test), so its result has to
|
||||||
|
|
@ -761,6 +702,7 @@ async function createRecipeInternal(
|
||||||
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
},
|
},
|
||||||
include: recipeInclude(authorId),
|
include: recipeInclude(authorId),
|
||||||
});
|
});
|
||||||
|
|
@ -778,25 +720,24 @@ async function createRecipeInternal(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return toRecipeView(created, await loadGlutenFreeDiet());
|
return toRecipeView(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||||
* and the complete ingredient/step lists (not a partial merge: a line
|
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
||||||
* missing from `input` is removed, same contract as `PATCH
|
* missing from `input` is removed, same contract as `PATCH
|
||||||
* /profile/allergies`). Regimes aren't part of the payload — they're
|
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
||||||
* recomputed from the ingredients on the next read (issue #131).
|
* never transfers ownership.
|
||||||
* `authorId`/`authorHouseId` are untouched — editing never transfers
|
|
||||||
* ownership.
|
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
*/
|
*/
|
||||||
export async function updateRecipe(
|
export async function updateRecipe(
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -811,9 +752,8 @@ export async function updateRecipe(
|
||||||
.map((i) => i.ingredientId)
|
.map((i) => i.ingredientId)
|
||||||
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
||||||
);
|
);
|
||||||
await assertUnitsExist(
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
await assertDietsExist(input.dietIds);
|
||||||
);
|
|
||||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
// Interactive transaction (not the array form) so any new free-text
|
// Interactive transaction (not the array form) so any new free-text
|
||||||
|
|
@ -824,6 +764,7 @@ export async function updateRecipe(
|
||||||
const createdPlaceholders = await prisma.$transaction(async (tx) => {
|
const createdPlaceholders = await prisma.$transaction(async (tx) => {
|
||||||
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
||||||
await tx.step.deleteMany({ where: { recipeId: id } });
|
await tx.step.deleteMany({ where: { recipeId: id } });
|
||||||
|
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
|
||||||
const { resolved, createdPlaceholders } = await resolveIngredientLines(
|
const { resolved, createdPlaceholders } = await resolveIngredientLines(
|
||||||
input.ingredients,
|
input.ingredients,
|
||||||
viewerId,
|
viewerId,
|
||||||
|
|
@ -858,6 +799,7 @@ export async function updateRecipe(
|
||||||
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
techSteps: { create: toStepTechStepCreateInputs(matches) },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return createdPlaceholders;
|
return createdPlaceholders;
|
||||||
|
|
@ -870,9 +812,9 @@ export async function updateRecipe(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet());
|
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -905,7 +847,7 @@ export async function deleteRecipe(
|
||||||
|
|
||||||
await prisma.recipe.delete({ where: { id } });
|
await prisma.recipe.delete({ where: { id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -933,7 +875,7 @@ export async function addFavorite(
|
||||||
create: { userProfileId: viewerId, recipeId: id },
|
create: { userProfileId: viewerId, recipeId: id },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -944,7 +886,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
|
||||||
where: { userProfileId: viewerId, recipeId: id },
|
where: { userProfileId: viewerId, recipeId: id },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -967,7 +909,7 @@ export async function assertRecipeVisible(
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -983,7 +925,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
|
||||||
}
|
}
|
||||||
return recipe;
|
return recipe;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1002,7 +944,7 @@ async function assertIsAuthor(
|
||||||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1024,7 +966,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1046,6 +988,29 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHousehold()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||||
|
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const uniqueIds = [...new Set(dietIds)];
|
||||||
|
if (uniqueIds.length === 0) return;
|
||||||
|
const found = await prisma.diet.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== uniqueIds.length) {
|
||||||
|
const foundIds = new Set(found.map((diet) => diet.id));
|
||||||
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.DIET_NOT_FOUND,
|
||||||
|
`Diet(s) not found: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,21 +77,11 @@ export async function getUnits(): Promise<UnitView[]> {
|
||||||
/**
|
/**
|
||||||
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
||||||
* for why) — small, static list (see `reference-seed-data.ts`'s
|
* for why) — small, static list (see `reference-seed-data.ts`'s
|
||||||
* `TECH_STEPS`).
|
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
||||||
*
|
|
||||||
* Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text
|
|
||||||
* techniques users propose when the catalog falls short, issue #130): this
|
|
||||||
* is the *browsable* catalog, and a placeholder is a per-correction
|
|
||||||
* stand-in, not a real entry anyone should pick again.
|
|
||||||
*/
|
*/
|
||||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
try {
|
try {
|
||||||
const techSteps = await prisma.techStep.findMany({
|
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||||
where: { isPlaceholder: false },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
orderBy: { key: "asc" },
|
|
||||||
});
|
|
||||||
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see getDiets()'s catch comment above
|
throw err; // see getDiets()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
|
||||||
include: ReturnType<typeof shoppingListPlanningInclude>;
|
include: ReturnType<typeof shoppingListPlanningInclude>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */
|
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
|
||||||
interface RunningTotal {
|
interface RunningTotal {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
unit: UnitView | null;
|
unit: UnitView;
|
||||||
quantity: number | null;
|
quantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,31 +70,16 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const scale = item.portions / item.recipe.portions;
|
const scale = item.portions / item.recipe.portions;
|
||||||
for (const recipeIngredient of item.recipe.ingredients) {
|
for (const recipeIngredient of item.recipe.ingredients) {
|
||||||
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
|
|
||||||
// per ingredient, never scaled or summed. Its key can't collide with a
|
|
||||||
// real `(ingredient, unit)` line for the same ingredient.
|
|
||||||
if (recipeIngredient.quantity === null) {
|
|
||||||
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
|
|
||||||
if (!totals.has(seasoningKey)) {
|
|
||||||
totals.set(seasoningKey, {
|
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
|
||||||
unit: null,
|
|
||||||
quantity: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
||||||
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
||||||
|
|
||||||
const existing = totals.get(key);
|
const existing = totals.get(key);
|
||||||
if (existing && existing.quantity !== null) {
|
if (existing) {
|
||||||
existing.quantity += addedQuantity;
|
existing.quantity += addedQuantity;
|
||||||
} else {
|
} else {
|
||||||
totals.set(key, {
|
totals.set(key, {
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
unit: toUnitView(recipeIngredient.unit),
|
||||||
quantity: addedQuantity,
|
quantity: addedQuantity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,20 +180,9 @@ export async function previewSourceItem(
|
||||||
),
|
),
|
||||||
loadIngredientCatalog(adapter.locale),
|
loadIngredientCatalog(adapter.locale),
|
||||||
loadUnitCatalog(adapter.locale),
|
loadUnitCatalog(adapter.locale),
|
||||||
// Real catalog techniques only — a draft preview is never a saved
|
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||||
// recipe, so it can't carry a user-proposed placeholder technique
|
|
||||||
// (issue #130); `isPlaceholder`/`displayName` are the neutral values.
|
|
||||||
prisma.techStep.findMany({
|
|
||||||
where: { isPlaceholder: false },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
const techStepById = new Map(
|
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||||
techStepsByKey.map((techStep) => [
|
|
||||||
techStep.id,
|
|
||||||
{ ...techStep, isPlaceholder: false, displayName: null },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const translatedIngredients = translateRecipeIngredients(
|
const translatedIngredients = translateRecipeIngredients(
|
||||||
parsed.ingredients,
|
parsed.ingredients,
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,11 @@ const SOURCE_KEY = "mangerBouger";
|
||||||
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
|
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
|
||||||
// plain GET carries the exact same Redux state the client hydrates from as
|
// plain GET carries the exact same Redux state the client hydrates from as
|
||||||
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
|
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
|
||||||
// has everything `list()` needs. Verified live for the *search* path
|
// has everything `list()` needs. Verified live: `?query=<free text>` really
|
||||||
// (`?diet=ALL&page=N&query=<free text>`): `query` really filters
|
// filters server-side (not just a client-side URL update over an
|
||||||
// server-side (not just a client-side URL update over an already-fetched
|
// already-fetched page), and `page`/`hasMorePages` behave as real,
|
||||||
// page), and `page`/`hasMorePages` behave as real, consistent pagination.
|
// consistent pagination — the best-behaved of this adapter family's three
|
||||||
// The *catalogue browse* path (no `query` term) must be the bare
|
// sources on that front.
|
||||||
// `?diet=ALL&page=N` — a trailing empty `&query=` made the deployed server
|
|
||||||
// repeat page 1 for every page (issue #78); `list()` below now omits it.
|
|
||||||
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
|
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
|
||||||
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
|
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
|
||||||
|
|
||||||
|
|
@ -44,7 +42,7 @@ interface MangerBougerListPageData {
|
||||||
initialState?: {
|
initialState?: {
|
||||||
recipes?: {
|
recipes?: {
|
||||||
list?: MangerBougerListEntry[];
|
list?: MangerBougerListEntry[];
|
||||||
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live on the `query`-term path: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). The catalogue-browse path (no `query`) only paginates when the request URL omits `&query=` entirely — see `LIST_URL` and issue #78. */
|
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
|
||||||
hasMorePages?: boolean;
|
hasMorePages?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -286,18 +284,8 @@ export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
try {
|
try {
|
||||||
const page = params.cursor ? Number(params.cursor) : 1;
|
const page = params.cursor ? Number(params.cursor) : 1;
|
||||||
// Only append `&query=` when there's an actual search term. A trailing
|
const query = params.query ?? "";
|
||||||
// empty `&query=` on the "browse everything" path (filter absent) made
|
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
|
||||||
// the *deployed* server serve page 1 for every `page=N` — the
|
|
||||||
// infinite scroll then stopped after 9 recipes (issue #38's
|
|
||||||
// "0 new externalIds ⇒ end of list" guard cutting in). The bare
|
|
||||||
// `?diet=ALL&page=N` catalogue request paginates correctly. The
|
|
||||||
// sibling adapters (marmiton/quitoque/ohmonbento) already make their
|
|
||||||
// search param conditional; this one didn't.
|
|
||||||
const query = (params.query ?? "").trim();
|
|
||||||
const listUrl = `${LIST_URL}?diet=ALL&page=${page}${
|
|
||||||
query ? `&query=${encodeURIComponent(query)}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export async function resetDatabase() {
|
||||||
"recipe", "ingredients", "sources", "unit",
|
"recipe", "ingredients", "sources", "unit",
|
||||||
"user_profiles", "diet", "house",
|
"user_profiles", "diet", "house",
|
||||||
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
|
"tour_progress", "tour_scenario_step", "tour_scenario", "tour_target",
|
||||||
"contact_message",
|
|
||||||
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
|
"admin_users", "analytics_events", "worker_heartbeats", "backfill_state"
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
`);
|
`);
|
||||||
|
|
|
||||||
|
|
@ -139,118 +139,3 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
|
|
||||||
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
|
|
||||||
* behind `requireAdmin`.
|
|
||||||
*/
|
|
||||||
describe("Admin catalog — missing techniques", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function adminAgent() {
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
|
|
||||||
async function seedPlaceholderTechnique(name: string): Promise<number> {
|
|
||||||
const author = await prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: "T",
|
|
||||||
lastName: "A",
|
|
||||||
email: `${faker.string.uuid()}@example.test`,
|
|
||||||
passwordHash: "x",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const techStep = await prisma.techStep.create({
|
|
||||||
data: {
|
|
||||||
key: `placeholder:${faker.string.uuid()}`,
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: name,
|
|
||||||
createdById: author.id,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: faker.lorem.words(3),
|
|
||||||
authorId: author.id,
|
|
||||||
portions: 2,
|
|
||||||
visibility: "PUBLIC",
|
|
||||||
steps: {
|
|
||||||
create: [
|
|
||||||
{
|
|
||||||
description: "Une étape.",
|
|
||||||
order: 0,
|
|
||||||
techSteps: {
|
|
||||||
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return techStep.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("rejects both routes without an admin session", async () => {
|
|
||||||
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await request(app)
|
|
||||||
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
|
||||||
.send({ techStepIds: [1] })
|
|
||||||
).status,
|
|
||||||
).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
|
|
||||||
await seedPlaceholderTechnique("Flamber au chalumeau");
|
|
||||||
await seedPlaceholderTechnique("Confire à basse température");
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(2);
|
|
||||||
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
|
|
||||||
"Flamber au chalumeau",
|
|
||||||
"Confire à basse température",
|
|
||||||
]);
|
|
||||||
expect(res.body[0].recipeCount).to.equal(1);
|
|
||||||
expect(res.body[0].reviewedAt).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
|
|
||||||
const id = await seedPlaceholderTechnique("Snacker à la plancha");
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const patched = await agent
|
|
||||||
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
|
||||||
.send({ techStepIds: [id] });
|
|
||||||
expect(patched.status).to.equal(200);
|
|
||||||
expect(patched.body.reviewed).to.equal(1);
|
|
||||||
expect(
|
|
||||||
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
|
||||||
).to.be.an.instanceOf(Date);
|
|
||||||
|
|
||||||
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
|
|
||||||
const reviewed = await agent
|
|
||||||
.get("/admin/api/catalog/tech-step-placeholders")
|
|
||||||
.query({ reviewed: "true" });
|
|
||||||
expect(reviewed.body).to.have.length(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { env } from "../src/config/env.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
||||||
const email = faker.internet.email().toLowerCase();
|
|
||||||
const password = faker.internet.password({ length: 16 });
|
|
||||||
await prisma.adminUser.create({
|
|
||||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
||||||
});
|
|
||||||
return { email, password };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates a `UserProfile` to attribute contact messages to. */
|
|
||||||
async function seedUser() {
|
|
||||||
return prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: faker.person.firstName(),
|
|
||||||
lastName: faker.person.lastName(),
|
|
||||||
email: `${faker.string.uuid()}@example.test`,
|
|
||||||
passwordHash: "x",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One `ContactMessage`, `createdAt` overridable so ordering is deterministic. */
|
|
||||||
async function seedMessage(overrides: {
|
|
||||||
authorId: number | null;
|
|
||||||
type?: string;
|
|
||||||
message?: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
}) {
|
|
||||||
return prisma.contactMessage.create({
|
|
||||||
data: {
|
|
||||||
authorId: overrides.authorId,
|
|
||||||
type: overrides.type ?? "bug",
|
|
||||||
message: overrides.message ?? faker.lorem.sentence(),
|
|
||||||
pageUrl: "/",
|
|
||||||
userAgent: "test-agent/1.0",
|
|
||||||
appVersion: "0.2.1",
|
|
||||||
...(overrides.createdAt ? { createdAt: overrides.createdAt } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
|
||||||
const forgejoConfigured =
|
|
||||||
env.FORGEJO_BASE_URL !== undefined &&
|
|
||||||
env.FORGEJO_TOKEN !== undefined &&
|
|
||||||
env.FORGEJO_REPO !== undefined;
|
|
||||||
|
|
||||||
describe("Admin contact messages", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function adminAgent() {
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("rejects the route without an admin session", async () => {
|
|
||||||
const res = await request(app).get("/admin/api/contact");
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns messages newest first, with the author resolved", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = await seedUser();
|
|
||||||
await seedMessage({
|
|
||||||
authorId: user.id,
|
|
||||||
message: "le plus ancien",
|
|
||||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
||||||
});
|
|
||||||
await seedMessage({
|
|
||||||
authorId: user.id,
|
|
||||||
message: "le plus récent",
|
|
||||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/contact");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.map((row: { message: string }) => row.message)).to.deep.equal([
|
|
||||||
"le plus récent",
|
|
||||||
"le plus ancien",
|
|
||||||
]);
|
|
||||||
expect(res.body[0].author).to.deep.equal({
|
|
||||||
id: user.id,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by type", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = await seedUser();
|
|
||||||
await seedMessage({ authorId: user.id, type: "bug" });
|
|
||||||
await seedMessage({ authorId: user.id, type: "suggestion" });
|
|
||||||
await seedMessage({ authorId: user.id, type: "suggestion" });
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/contact").query({ type: "suggestion" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.lengthOf(2);
|
|
||||||
expect(res.body.every((row: { type: string }) => row.type === "suggestion")).to.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown type filter with 400", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/contact").query({ type: "complaint" });
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps the page at 200 rows", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = await seedUser();
|
|
||||||
await prisma.contactMessage.createMany({
|
|
||||||
data: Array.from({ length: 205 }, () => ({
|
|
||||||
authorId: user.id,
|
|
||||||
type: "bug",
|
|
||||||
message: faker.lorem.sentence(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/contact");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.lengthOf(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports author: null once the sender's account is deleted", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = await seedUser();
|
|
||||||
await seedMessage({ authorId: user.id, message: "signalé puis compte supprimé" });
|
|
||||||
await prisma.userProfile.delete({ where: { id: user.id } });
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/contact");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.lengthOf(1);
|
|
||||||
expect(res.body[0].message).to.equal("signalé puis compte supprimé");
|
|
||||||
expect(res.body[0].author).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Forgejo issue creation (#81)", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = globalThis.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Makes `forgejo.createIssue`'s `fetch` return `body` with `status`. */
|
|
||||||
function stubForgejoFetch(body: unknown, status = 201) {
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
new Response(JSON.stringify(body), { status })) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("returns 401 without an admin session", async () => {
|
|
||||||
const res = await request(app).post("/admin/api/contact/1/forgejo-issue").send({
|
|
||||||
title: "t",
|
|
||||||
body: "b",
|
|
||||||
});
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the integration status", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/api/contact/forgejo/status");
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.deep.equal({ enabled: forgejoConfigured });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("responds 503 FORGEJO_NOT_CONFIGURED when the env isn't set", async function () {
|
|
||||||
if (!adminSecretConfigured || forgejoConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = await seedUser();
|
|
||||||
const message = await seedMessage({ authorId: user.id });
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
|
||||||
.send({ title: "Titre", body: "Corps du ticket" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(503);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.FORGEJO_NOT_CONFIGURED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates the issue and records the link on the message", async function () {
|
|
||||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stubForgejoFetch({ number: 77, html_url: "https://forge.example/ci/repo/issues/77" });
|
|
||||||
const user = await seedUser();
|
|
||||||
const message = await seedMessage({ authorId: user.id, message: "un bug précis" });
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
|
||||||
.send({ title: "[Bug] un bug précis", body: "un bug précis\n\n---\ncontexte" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.forgejoIssueNumber).to.equal(77);
|
|
||||||
expect(res.body.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
|
|
||||||
|
|
||||||
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
|
|
||||||
expect(row.forgejoIssueNumber).to.equal(77);
|
|
||||||
expect(row.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("responds 404 for an unknown message id", async function () {
|
|
||||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stubForgejoFetch({ number: 1, html_url: "x" });
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post("/admin/api/contact/999999/forgejo-issue")
|
|
||||||
.send({ title: "Titre", body: "Corps" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("responds 409 when the message already links to an issue", async function () {
|
|
||||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stubForgejoFetch({ number: 2, html_url: "x" });
|
|
||||||
const user = await seedUser();
|
|
||||||
const message = await seedMessage({ authorId: user.id });
|
|
||||||
await prisma.contactMessage.update({
|
|
||||||
where: { id: message.id },
|
|
||||||
data: { forgejoIssueNumber: 5, forgejoIssueUrl: "https://forge.example/ci/repo/issues/5" },
|
|
||||||
});
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
|
||||||
.send({ title: "Titre", body: "Corps" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(409);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the message untouched when the Forgejo call fails (502)", async function () {
|
|
||||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stubForgejoFetch("upstream boom", 500);
|
|
||||||
const user = await seedUser();
|
|
||||||
const message = await seedMessage({ authorId: user.id });
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
|
||||||
.send({ title: "Titre", body: "Corps" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(502);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
|
||||||
|
|
||||||
const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } });
|
|
||||||
expect(row.forgejoIssueNumber).to.equal(null);
|
|
||||||
expect(row.forgejoIssueUrl).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an empty title with 400", async function () {
|
|
||||||
if (!adminSecretConfigured || !forgejoConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = await seedUser();
|
|
||||||
const message = await seedMessage({ authorId: user.id });
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/admin/api/contact/${message.id}/forgejo-issue`)
|
|
||||||
.send({ title: " ", body: "Corps" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
||||||
const email = faker.internet.email().toLowerCase();
|
|
||||||
const password = faker.internet.password({ length: 16 });
|
|
||||||
await prisma.adminUser.create({
|
|
||||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
||||||
});
|
|
||||||
return { email, password };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A fresh, fake (never real-looking) create-user payload — same rationale as `auth.test.ts`'s `buildSignupPayload`. */
|
|
||||||
function buildCreateUserPayload() {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/admin/api/users/*` — the admin surface for provisioning end-user
|
|
||||||
* accounts by hand (`/admin/utilisateurs` page). Every route behind
|
|
||||||
* `requireAdmin`. Creation generates a one-time temporary password and
|
|
||||||
* returns it in the response; the account starts with no household, exactly
|
|
||||||
* like a self-service signup.
|
|
||||||
*/
|
|
||||||
describe("Admin users — account provisioning", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function adminAgent() {
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/api/auth/login").send({ email, password });
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("rejects every route without an admin session", async () => {
|
|
||||||
expect((await request(app).get("/admin/api/users")).status).to.equal(401);
|
|
||||||
expect(
|
|
||||||
(await request(app).post("/admin/api/users").send(buildCreateUserPayload())).status,
|
|
||||||
).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a profile with no household and returns a one-time temporary password", async () => {
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const payload = buildCreateUserPayload();
|
|
||||||
|
|
||||||
const res = await agent.post("/admin/api/users").send(payload);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.user).to.include({
|
|
||||||
firstName: payload.firstName,
|
|
||||||
lastName: payload.lastName,
|
|
||||||
email: payload.email,
|
|
||||||
householdName: null,
|
|
||||||
});
|
|
||||||
expect(res.body.user).to.not.have.property("passwordHash");
|
|
||||||
expect(res.body.user).to.not.have.property("tokenVersion");
|
|
||||||
expect(res.body.temporaryPassword).to.be.a("string").with.length.greaterThan(8);
|
|
||||||
|
|
||||||
const stored = await prisma.userProfile.findUnique({ where: { email: payload.email } });
|
|
||||||
expect(stored?.houseId).to.equal(null);
|
|
||||||
expect(stored?.dietId).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("issues a password the new account can actually log in with", async () => {
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const payload = buildCreateUserPayload();
|
|
||||||
|
|
||||||
const created = await agent.post("/admin/api/users").send(payload);
|
|
||||||
const login = await request(app)
|
|
||||||
.post("/auth/login")
|
|
||||||
.send({ email: payload.email, password: created.body.temporaryPassword });
|
|
||||||
|
|
||||||
expect(login.status).to.equal(200);
|
|
||||||
expect(login.body.email).to.equal(payload.email);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const payload = buildCreateUserPayload();
|
|
||||||
await agent.post("/admin/api/users").send(payload);
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post("/admin/api/users")
|
|
||||||
.send({ ...buildCreateUserPayload(), email: payload.email });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(409);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an invalid body with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const badEmail = await agent
|
|
||||||
.post("/admin/api/users")
|
|
||||||
.send({ ...buildCreateUserPayload(), email: "not-an-email" });
|
|
||||||
expect(badEmail.status).to.equal(400);
|
|
||||||
expect(badEmail.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
|
|
||||||
const noFirstName = await agent
|
|
||||||
.post("/admin/api/users")
|
|
||||||
.send({ ...buildCreateUserPayload(), firstName: "" });
|
|
||||||
expect(noFirstName.status).to.equal(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists accounts newest-first with their household name resolved, never the password", async () => {
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const first = buildCreateUserPayload();
|
|
||||||
const second = buildCreateUserPayload();
|
|
||||||
const firstRes = await agent.post("/admin/api/users").send(first);
|
|
||||||
await agent.post("/admin/api/users").send(second);
|
|
||||||
|
|
||||||
// Put the first account in a household so `householdName` has something to
|
|
||||||
// resolve — `House.adminId` is required, so the account is its own admin.
|
|
||||||
const house = await prisma.house.create({
|
|
||||||
data: {
|
|
||||||
name: "Le Foyer Test",
|
|
||||||
adminId: firstRes.body.user.id,
|
|
||||||
inviteCode: faker.string.alphanumeric(8).toUpperCase(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.userProfile.update({
|
|
||||||
where: { email: first.email },
|
|
||||||
data: { houseId: house.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.get("/admin/api/users");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(2);
|
|
||||||
// Newest first: `second` was created after `first`.
|
|
||||||
expect(res.body[0].email).to.equal(second.email);
|
|
||||||
expect(res.body[1].email).to.equal(first.email);
|
|
||||||
expect(res.body[1].householdName).to.equal("Le Foyer Test");
|
|
||||||
expect(res.body[0].householdName).to.equal(null);
|
|
||||||
for (const row of res.body) {
|
|
||||||
expect(row).to.not.have.property("temporaryPassword");
|
|
||||||
expect(row).to.not.have.property("passwordHash");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
|
||||||
function buildSignupPayload(): SignupInput {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
password: faker.internet.password({ length: 16 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A valid `POST /contact` body — message comfortably over the 10-char minimum. */
|
|
||||||
function buildContactPayload() {
|
|
||||||
return {
|
|
||||||
type: "bug" as const,
|
|
||||||
message: "Le bouton « Envoyer » ne réagit pas sur la page planning.",
|
|
||||||
pageUrl: "/",
|
|
||||||
appVersion: "0.2.1",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Contact", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /contact", () => {
|
|
||||||
it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => {
|
|
||||||
const res = await request(app).post("/contact").send(buildContactPayload());
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
const signup = buildSignupPayload();
|
|
||||||
await agent.post("/auth/signup").send(signup);
|
|
||||||
const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } });
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post("/contact")
|
|
||||||
.set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)")
|
|
||||||
.send(buildContactPayload());
|
|
||||||
|
|
||||||
expect(res.status).to.equal(204);
|
|
||||||
|
|
||||||
const rows = await prisma.contactMessage.findMany();
|
|
||||||
expect(rows).to.have.lengthOf(1);
|
|
||||||
const [row] = rows;
|
|
||||||
expect(row.authorId).to.equal(author.id);
|
|
||||||
expect(row.type).to.equal("bug");
|
|
||||||
expect(row.message).to.equal(buildContactPayload().message);
|
|
||||||
expect(row.pageUrl).to.equal("/");
|
|
||||||
expect(row.appVersion).to.equal("0.2.1");
|
|
||||||
// Read from the request header, never the body.
|
|
||||||
expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores any user-agent sent in the body — the header is the only source", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post("/contact")
|
|
||||||
.set("User-Agent", "real-agent/1.0")
|
|
||||||
.send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(204);
|
|
||||||
const row = await prisma.contactMessage.findFirstOrThrow();
|
|
||||||
expect(row.userAgent).to.equal("real-agent/1.0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps pageUrl / appVersion null when the client omits them", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.post("/contact").send({
|
|
||||||
type: "suggestion",
|
|
||||||
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(204);
|
|
||||||
const row = await prisma.contactMessage.findFirstOrThrow();
|
|
||||||
expect(row.type).to.equal("suggestion");
|
|
||||||
expect(row.pageUrl).to.equal(null);
|
|
||||||
expect(row.appVersion).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
// 5 chars — and the schema trims before counting, so leading/trailing
|
|
||||||
// whitespace can't pad it to the minimum.
|
|
||||||
const res = await agent.post("/contact").send({ type: "bug", message: " court " });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown type with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post("/contact")
|
|
||||||
.send({ type: "complaint", message: "Ceci est un message de test suffisamment long." });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import { envSchema } from "../src/config/env.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure schema test — no DB, no `process.env`. Covers `blankAsUnset` on the
|
|
||||||
* `FORGEJO_*` trio: `docker-compose.yml` forwards every optional var as
|
|
||||||
* `${FOO:-}`, which the container receives as `""` (not an absent key) when
|
|
||||||
* the stack's `.env` leaves it blank. Those three must read a blank value
|
|
||||||
* as "not configured" and still boot, rather than trip `FORGEJO_BASE_URL`'s
|
|
||||||
* `.url()` / `FORGEJO_REPO`'s regex and abort `parse()` at startup.
|
|
||||||
*/
|
|
||||||
describe("envSchema — FORGEJO_* blank handling", () => {
|
|
||||||
// The only two vars with no default and no `.optional()`.
|
|
||||||
const base = {
|
|
||||||
JWT_SECRET: "x".repeat(32),
|
|
||||||
INTENT_SERVICE_SECRET: "y".repeat(32),
|
|
||||||
};
|
|
||||||
|
|
||||||
it("treats blank FORGEJO_* values as unset instead of failing validation", () => {
|
|
||||||
const parsed = envSchema.parse({
|
|
||||||
...base,
|
|
||||||
FORGEJO_BASE_URL: "",
|
|
||||||
FORGEJO_TOKEN: "",
|
|
||||||
FORGEJO_REPO: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
|
|
||||||
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
|
|
||||||
expect(parsed.FORGEJO_REPO).to.equal(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a fully absent FORGEJO_* trio the same way", () => {
|
|
||||||
const parsed = envSchema.parse({ ...base });
|
|
||||||
expect(parsed.FORGEJO_BASE_URL).to.equal(undefined);
|
|
||||||
expect(parsed.FORGEJO_TOKEN).to.equal(undefined);
|
|
||||||
expect(parsed.FORGEJO_REPO).to.equal(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still passes through and validates non-blank FORGEJO_* values", () => {
|
|
||||||
const parsed = envSchema.parse({
|
|
||||||
...base,
|
|
||||||
FORGEJO_BASE_URL: "https://git.example.com",
|
|
||||||
FORGEJO_TOKEN: "a-token",
|
|
||||||
FORGEJO_REPO: "owner/repo",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.FORGEJO_BASE_URL).to.equal("https://git.example.com");
|
|
||||||
expect(parsed.FORGEJO_TOKEN).to.equal("a-token");
|
|
||||||
expect(parsed.FORGEJO_REPO).to.equal("owner/repo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still rejects a non-blank but malformed FORGEJO_BASE_URL", () => {
|
|
||||||
expect(() => envSchema.parse({ ...base, FORGEJO_BASE_URL: "git.example.com" })).to.throw();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still rejects a non-blank but malformed FORGEJO_REPO", () => {
|
|
||||||
expect(() => envSchema.parse({ ...base, FORGEJO_REPO: "not-a-repo" })).to.throw();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import { env } from "../src/config/env.js";
|
|
||||||
import { forgejo } from "../src/lib/forgejo/forgejo.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs `globalThis.fetch` for one test — same approach as
|
|
||||||
* `recipe-sources/the-meal-db.test.ts` (no HTTP-mock library in this repo).
|
|
||||||
* `capture` collects the `(url, init)` each call was made with, so a test
|
|
||||||
* can assert the request shape. Restored by `afterEach`.
|
|
||||||
*/
|
|
||||||
function stubFetch(
|
|
||||||
responder: (url: string, init: RequestInit | undefined) => Response | Promise<Response>,
|
|
||||||
capture: { url: string; init: RequestInit | undefined }[],
|
|
||||||
) {
|
|
||||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
|
||||||
capture.push({ url, init });
|
|
||||||
return responder(url, init);
|
|
||||||
}) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configured = forgejo.isConfigured();
|
|
||||||
|
|
||||||
describe("ForgejoService", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = globalThis.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isConfigured() reports whether all three FORGEJO_* vars are set", () => {
|
|
||||||
expect(forgejo.isConfigured()).to.equal(
|
|
||||||
env.FORGEJO_BASE_URL !== undefined &&
|
|
||||||
env.FORGEJO_TOKEN !== undefined &&
|
|
||||||
env.FORGEJO_REPO !== undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createIssue", () => {
|
|
||||||
it("POSTs to the repo's issues endpoint with a `token` auth header and { title, body }", async function () {
|
|
||||||
if (!configured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const calls: { url: string; init: RequestInit | undefined }[] = [];
|
|
||||||
stubFetch(
|
|
||||||
() =>
|
|
||||||
new Response(JSON.stringify({ number: 42, html_url: "https://forge/x/y/issues/42" }), {
|
|
||||||
status: 201,
|
|
||||||
}),
|
|
||||||
calls,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await forgejo.createIssue({ title: "Un titre", body: "Un corps" });
|
|
||||||
|
|
||||||
expect(result).to.deep.equal({ number: 42, htmlUrl: "https://forge/x/y/issues/42" });
|
|
||||||
expect(calls).to.have.lengthOf(1);
|
|
||||||
const [call] = calls;
|
|
||||||
expect(call.url).to.equal(`${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`);
|
|
||||||
expect(call.init?.method).to.equal("POST");
|
|
||||||
const headers = call.init?.headers as Record<string, string>;
|
|
||||||
expect(headers.Authorization).to.equal(`token ${env.FORGEJO_TOKEN}`);
|
|
||||||
expect(JSON.parse(String(call.init?.body))).to.deep.equal({
|
|
||||||
title: "Un titre",
|
|
||||||
body: "Un corps",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws 502 FORGEJO_REQUEST_FAILED on a non-2xx response", async function () {
|
|
||||||
if (!configured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stubFetch(() => new Response("nope", { status: 403 }), []);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await forgejo.createIssue({ title: "t", body: "b" });
|
|
||||||
expect.fail("expected createIssue to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(HttpError);
|
|
||||||
expect((err as HttpError).status).to.equal(502);
|
|
||||||
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws 502 FORGEJO_REQUEST_FAILED on a network error", async function () {
|
|
||||||
if (!configured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stubFetch(() => {
|
|
||||||
throw new Error("ECONNREFUSED");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await forgejo.createIssue({ title: "t", body: "b" });
|
|
||||||
expect.fail("expected createIssue to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(HttpError);
|
|
||||||
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { computeRecipeDietViews, type DietView, type IngredientView } from "@batch-cooking/shared";
|
|
||||||
import { expect } from "chai";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure regime derivation from `@batch-cooking/shared` — no DB, so this suite
|
|
||||||
* skips `resetDatabase`. `recipe.service.ts`'s `toRecipeSummaryView` calls
|
|
||||||
* it for every recipe read (issue #131 dropped the hand-tagged `RecipeDiet`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const VEGETARIAN: DietView = { id: 1, key: "vegetarian" };
|
|
||||||
const VEGAN: DietView = { id: 2, key: "vegan" };
|
|
||||||
const PESCATARIAN: DietView = { id: 3, key: "pescatarian" };
|
|
||||||
const GLUTEN_FREE: DietView = { id: 4, key: "glutenFree" };
|
|
||||||
|
|
||||||
/** A minimal ingredient fixture — only the fields `computeRecipeDietViews` reads. */
|
|
||||||
function ingredient(
|
|
||||||
diets: DietView[],
|
|
||||||
opts: { gluten?: boolean; placeholder?: boolean } = {},
|
|
||||||
): Pick<IngredientView, "diets" | "allergens" | "isPlaceholder"> {
|
|
||||||
return {
|
|
||||||
diets,
|
|
||||||
allergens: opts.gluten ? [{ id: 9, key: "gluten", kind: "INTOLERANCE" }] : [],
|
|
||||||
isPlaceholder: opts.placeholder ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("computeRecipeDietViews", () => {
|
|
||||||
it("keeps only the regimes every ingredient is compatible with", () => {
|
|
||||||
const result = computeRecipeDietViews(
|
|
||||||
[
|
|
||||||
ingredient([VEGETARIAN, VEGAN, PESCATARIAN]),
|
|
||||||
ingredient([VEGETARIAN, PESCATARIAN]), // e.g. egg — not vegan
|
|
||||||
],
|
|
||||||
GLUTEN_FREE,
|
|
||||||
);
|
|
||||||
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "pescatarian", "glutenFree"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops every ingredient-derived regime when one ingredient is compatible with none", () => {
|
|
||||||
const result = computeRecipeDietViews(
|
|
||||||
[ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([])],
|
|
||||||
GLUTEN_FREE,
|
|
||||||
);
|
|
||||||
// Still gluten-free — nothing carries the allergen.
|
|
||||||
expect(result.map((d) => d.key)).to.deep.equal(["glutenFree"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is not gluten-free when any ingredient carries the gluten allergen", () => {
|
|
||||||
const result = computeRecipeDietViews(
|
|
||||||
[
|
|
||||||
ingredient([VEGETARIAN, VEGAN, PESCATARIAN]),
|
|
||||||
ingredient([VEGETARIAN, VEGAN, PESCATARIAN], { gluten: true }),
|
|
||||||
],
|
|
||||||
GLUTEN_FREE,
|
|
||||||
);
|
|
||||||
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [] for a recipe with any placeholder (off-catalog) ingredient", () => {
|
|
||||||
const result = computeRecipeDietViews(
|
|
||||||
[ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([], { placeholder: true })],
|
|
||||||
GLUTEN_FREE,
|
|
||||||
);
|
|
||||||
expect(result).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns [] when there are no ingredients", () => {
|
|
||||||
expect(computeRecipeDietViews([], GLUTEN_FREE)).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits gluten-free entirely when no glutenFree DietView is supplied", () => {
|
|
||||||
const result = computeRecipeDietViews(
|
|
||||||
[ingredient([VEGAN, VEGETARIAN, PESCATARIAN])],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("borrows each regime's DietView (id + key) from the ingredients themselves", () => {
|
|
||||||
const result = computeRecipeDietViews(
|
|
||||||
[ingredient([VEGETARIAN]), ingredient([VEGETARIAN])],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(result).to.deep.equal([VEGETARIAN]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -62,6 +62,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Poulet basquaise",
|
name: "Poulet basquaise",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
||||||
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
||||||
|
|
@ -98,6 +99,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
await agent.post("/recipes").send({
|
await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "x" }],
|
steps: [{ description: "x" }],
|
||||||
});
|
});
|
||||||
|
|
@ -120,6 +122,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Édition",
|
name: "Édition",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "x" }],
|
steps: [{ description: "x" }],
|
||||||
});
|
});
|
||||||
|
|
@ -129,6 +132,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Édition",
|
name: "Édition",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
||||||
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
||||||
|
|
@ -142,6 +146,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
await agent.patch(`/recipes/${created.body.id}`).send({
|
await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Édition",
|
name: "Édition",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "x" }],
|
steps: [{ description: "x" }],
|
||||||
});
|
});
|
||||||
|
|
@ -158,6 +163,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Invalide",
|
name: "Invalide",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
||||||
],
|
],
|
||||||
|
|
@ -175,6 +181,7 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Doublons libres",
|
name: "Doublons libres",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
||||||
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
||||||
|
|
|
||||||
|
|
@ -132,59 +132,6 @@ describe("mangerBougerAdapter", () => {
|
||||||
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
|
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the query param entirely on the catalogue-browse path (no filter) — issue #78", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(htmlWithListNextData({ list: [], hasMorePages: true }), {
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await mangerBougerAdapter.list({});
|
|
||||||
|
|
||||||
expect(requestedUrl).to.equal(
|
|
||||||
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes?diet=ALL&page=1",
|
|
||||||
);
|
|
||||||
expect(requestedUrl).not.to.include("query=");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a blank/whitespace query the same as no filter (no query param)", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await mangerBougerAdapter.list({ query: " " });
|
|
||||||
|
|
||||||
expect(requestedUrl).not.to.include("query=");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still paginates the catalogue-browse path (nextCursor set when hasMorePages)", async () => {
|
|
||||||
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
|
|
||||||
|
|
||||||
const result = await mangerBougerAdapter.list({});
|
|
||||||
|
|
||||||
expect(result.nextCursor).to.equal("2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still appends the query param when a real search term is given", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await mangerBougerAdapter.list({ query: "salade" });
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("&query=salade");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips a list entry missing a slug or a name", async () => {
|
it("skips a list entry missing a slug or a name", async () => {
|
||||||
stubFetchHtml(
|
stubFetchHtml(
|
||||||
htmlWithListNextData({
|
htmlWithListNextData({
|
||||||
|
|
|
||||||
|
|
@ -312,16 +312,20 @@ describe("Recipes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /recipes", () => {
|
describe("POST /recipes", () => {
|
||||||
it("creates a recipe with its ingredients and ordered steps, and computes its regimes from the ingredients", async () => {
|
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const oeuf = await ingredientId("egg");
|
const oeuf = await ingredientId("egg");
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
|
const vegetarien = await prisma.diet.findFirstOrThrow({
|
||||||
|
where: { key: "vegetarian" },
|
||||||
|
});
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Omelette provençale",
|
name: "Omelette provençale",
|
||||||
description: "Rapide et savoureuse",
|
description: "Rapide et savoureuse",
|
||||||
portions: 2,
|
portions: 2,
|
||||||
|
dietIds: [vegetarien.id],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||||
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
||||||
|
|
@ -339,84 +343,7 @@ describe("Recipes", () => {
|
||||||
).to.deep.equal([0, 1]);
|
).to.deep.equal([0, 1]);
|
||||||
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
||||||
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
|
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs");
|
||||||
// Regimes computed (issue #131), not tagged: tomato is vegetarian/
|
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]);
|
||||||
// vegan/pescatarian, egg is vegetarian/pescatarian (not vegan) — the
|
|
||||||
// intersection is vegetarian + pescatarian; neither carries gluten, so
|
|
||||||
// glutenFree too.
|
|
||||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
|
|
||||||
"vegetarian",
|
|
||||||
"pescatarian",
|
|
||||||
"glutenFree",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a regime the moment one ingredient is incompatible, keeps glutenFree if nothing carries gluten (issue #131)", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const poulet = await ingredientId("chicken");
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Poulet aux tomates",
|
|
||||||
portions: 2,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
|
||||||
{ ingredientId: poulet, quantity: 1, unitId: piece },
|
|
||||||
],
|
|
||||||
steps: [{ description: "Cuire le poulet" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
// Chicken is compatible with none of vegetarian/vegan/pescatarian, so
|
|
||||||
// the recipe qualifies for none of them — but it has no gluten.
|
|
||||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["glutenFree"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is not glutenFree when an ingredient carries the gluten allergen (issue #131)", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const farine = await ingredientId("wheatFlour");
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
const gram = await unitId("gram");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Galette de tomates",
|
|
||||||
portions: 2,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
|
||||||
{ ingredientId: farine, quantity: 100, unitId: gram },
|
|
||||||
],
|
|
||||||
steps: [{ description: "Mélanger" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
// Both ingredients are vegetarian/vegan/pescatarian, but wheat flour
|
|
||||||
// carries `gluten` — no glutenFree.
|
|
||||||
expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([
|
|
||||||
"vegetarian",
|
|
||||||
"vegan",
|
|
||||||
"pescatarian",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has no regimes at all when a recipe contains a placeholder ingredient (issue #131)", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Recette incomplète",
|
|
||||||
portions: 2,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
|
||||||
{ placeholderName: "Épice mystère", quantity: 1, unitId: piece },
|
|
||||||
],
|
|
||||||
steps: [{ description: "Assembler" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
// An off-catalog ingredient can't certify anything.
|
|
||||||
expect(res.body.diets).to.deep.equal([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
||||||
|
|
@ -428,6 +355,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -439,6 +367,7 @@ describe("Recipes", () => {
|
||||||
name: "Foyer",
|
name: "Foyer",
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -457,6 +386,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description }],
|
steps: [{ description }],
|
||||||
});
|
});
|
||||||
|
|
@ -469,12 +399,7 @@ describe("Recipes", () => {
|
||||||
// response itself carries exactly what to highlight, not just the id.
|
// response itself carries exactly what to highlight, not just the id.
|
||||||
const resStep = res.body.steps[0];
|
const resStep = res.body.steps[0];
|
||||||
expect(resStep.techSteps).to.have.length(1);
|
expect(resStep.techSteps).to.have.length(1);
|
||||||
expect(resStep.techSteps[0].techStep).to.deep.equal({
|
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
|
||||||
id: simmer,
|
|
||||||
key: "simmer",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
const { start, end } = resStep.techSteps[0];
|
const { start, end } = resStep.techSteps[0];
|
||||||
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
||||||
});
|
});
|
||||||
|
|
@ -488,6 +413,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description }],
|
steps: [{ description }],
|
||||||
});
|
});
|
||||||
|
|
@ -522,6 +448,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Servir immédiatement" }],
|
steps: [{ description: "Servir immédiatement" }],
|
||||||
});
|
});
|
||||||
|
|
@ -540,6 +467,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [
|
steps: [
|
||||||
{ description: "Hacher les oignons" },
|
{ description: "Hacher les oignons" },
|
||||||
|
|
@ -569,6 +497,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
||||||
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
||||||
|
|
@ -590,6 +519,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Poêlée",
|
name: "Poêlée",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
// The case that motivated the sequence model: one instruction, two techniques.
|
// The case that motivated the sequence model: one instruction, two techniques.
|
||||||
steps: [{ description }],
|
steps: [{ description }],
|
||||||
|
|
@ -620,6 +550,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -635,6 +566,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -643,12 +575,30 @@ describe("Recipes", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||||
|
const { agent } = await signup();
|
||||||
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
|
||||||
|
const res = await agent.post("/recipes").send({
|
||||||
|
name: "Test",
|
||||||
|
portions: 4,
|
||||||
|
dietIds: [999_999],
|
||||||
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
|
steps: [{ description: "Étape" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -671,6 +621,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomate, quantity: 1, unitId: piece },
|
{ ingredientId: tomate, quantity: 1, unitId: piece },
|
||||||
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||||
|
|
@ -688,6 +639,7 @@ describe("Recipes", () => {
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: "Test",
|
name: "Test",
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
};
|
};
|
||||||
|
|
@ -700,55 +652,6 @@ describe("Recipes", () => {
|
||||||
expect(zero.status).to.equal(400);
|
expect(zero.status).to.equal(400);
|
||||||
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a « selon le goût » seasoning line with no quantity or unit (issue #128)", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const sel = await ingredientId("salt");
|
|
||||||
const gram = await unitId("gram");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Tomates au sel",
|
|
||||||
portions: 2,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomate, quantity: 200, unitId: gram },
|
|
||||||
{ ingredientId: sel }, // selon le goût — no quantity/unit
|
|
||||||
],
|
|
||||||
steps: [{ description: "Saler les tomates" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
const seasoning = res.body.ingredients.find(
|
|
||||||
(line: { ingredient: { key: string } }) => line.ingredient.key === "salt",
|
|
||||||
);
|
|
||||||
expect(seasoning.quantity).to.equal(null);
|
|
||||||
expect(seasoning.unit).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an ingredient line with a quantity but no unit (or vice versa) with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const gram = await unitId("gram");
|
|
||||||
const base = {
|
|
||||||
name: "Test",
|
|
||||||
portions: 2,
|
|
||||||
steps: [{ description: "Étape" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const quantityOnly = await agent.post("/recipes").send({
|
|
||||||
...base,
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 100 }],
|
|
||||||
});
|
|
||||||
const unitOnly = await agent.post("/recipes").send({
|
|
||||||
...base,
|
|
||||||
ingredients: [{ ingredientId: tomate, unitId: gram }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(quantityOnly.status).to.equal(400);
|
|
||||||
expect(quantityOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
expect(unitOnly.status).to.equal(400);
|
|
||||||
expect(unitOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /recipes/:id", () => {
|
describe("GET /recipes/:id", () => {
|
||||||
|
|
@ -768,6 +671,7 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
@ -853,6 +757,7 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
@ -861,6 +766,7 @@ describe("Recipes", () => {
|
||||||
name: "Salade composée",
|
name: "Salade composée",
|
||||||
portions: 6,
|
portions: 6,
|
||||||
visibility: "PUBLIC",
|
visibility: "PUBLIC",
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
||||||
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
||||||
});
|
});
|
||||||
|
|
@ -875,36 +781,6 @@ describe("Recipes", () => {
|
||||||
expect(res.body.steps).to.have.length(2);
|
expect(res.body.steps).to.have.length(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("carries a « selon le goût » seasoning line through an edit (issue #128)", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const tomate = await ingredientId("tomato");
|
|
||||||
const poivre = await ingredientId("blackPepper");
|
|
||||||
const gram = await unitId("gram");
|
|
||||||
const created = await agent.post("/recipes").send({
|
|
||||||
name: "Salade",
|
|
||||||
portions: 4,
|
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
|
|
||||||
steps: [{ description: "Couper" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
||||||
name: "Salade poivrée",
|
|
||||||
portions: 4,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomate, quantity: 100, unitId: gram },
|
|
||||||
{ ingredientId: poivre }, // selon le goût
|
|
||||||
],
|
|
||||||
steps: [{ description: "Couper" }, { description: "Poivrer" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
const seasoning = res.body.ingredients.find(
|
|
||||||
(line: { ingredient: { key: string } }) => line.ingredient.key === "blackPepper",
|
|
||||||
);
|
|
||||||
expect(seasoning.quantity).to.equal(null);
|
|
||||||
expect(seasoning.unit).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recomputes each replaced step's technique sequence", async () => {
|
it("recomputes each replaced step's technique sequence", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
|
@ -913,6 +789,7 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Servir immédiatement" }],
|
steps: [{ description: "Servir immédiatement" }],
|
||||||
});
|
});
|
||||||
|
|
@ -920,6 +797,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Émincer les tomates" }],
|
steps: [{ description: "Émincer les tomates" }],
|
||||||
});
|
});
|
||||||
|
|
@ -942,6 +820,7 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [
|
steps: [
|
||||||
{ description: "Hacher les oignons" }, // chop
|
{ description: "Hacher les oignons" }, // chop
|
||||||
|
|
@ -970,6 +849,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Ragoût",
|
name: "Ragoût",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: editedDescription }, { description: addedDescription }],
|
steps: [{ description: editedDescription }, { description: addedDescription }],
|
||||||
});
|
});
|
||||||
|
|
@ -1010,6 +890,7 @@ describe("Recipes", () => {
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Servir immédiatement" }],
|
steps: [{ description: "Servir immédiatement" }],
|
||||||
});
|
});
|
||||||
|
|
@ -1026,6 +907,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Gratin",
|
name: "Gratin",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: editedDescription }],
|
steps: [{ description: editedDescription }],
|
||||||
});
|
});
|
||||||
|
|
@ -1060,6 +942,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.patch("/recipes/999999").send({
|
const res = await agent.patch("/recipes/999999").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
@ -1068,6 +951,30 @@ describe("Recipes", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||||
|
const { agent } = await signup();
|
||||||
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
const created = await agent.post("/recipes").send({
|
||||||
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
|
steps: [{ description: "Couper" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
|
dietIds: [999_999],
|
||||||
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
|
steps: [{ description: "Couper" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||||
const { profileId } = await signup();
|
const { profileId } = await signup();
|
||||||
const { agent: otherAgent } = await signup();
|
const { agent: otherAgent } = await signup();
|
||||||
|
|
@ -1080,6 +987,7 @@ describe("Recipes", () => {
|
||||||
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
||||||
name: "Hack",
|
name: "Hack",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.correction.previousTechStep).to.equal(null);
|
expect(res.body.correction.previousTechStep).to.equal(null);
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||||
id: simmerId,
|
|
||||||
key: "simmer",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
expect(res.body.correction.start).to.equal(6);
|
expect(res.body.correction.start).to.equal(6);
|
||||||
expect(res.body.correction.end).to.equal(13);
|
expect(res.body.correction.end).to.equal(13);
|
||||||
// The step's real technique sequence reflects the correction right
|
// The step's real technique sequence reflects the correction right
|
||||||
|
|
@ -122,7 +117,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
techStep: { id: simmerId, key: "simmer" },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -133,56 +128,6 @@ describe("Recipe tech-step corrections", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("proposes a placeholder technique via correctedTechStepName when the catalog is missing one (issue #130)", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepName: "Flamber au chalumeau" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
// A placeholder `TechStep` was minted and the correction points at it.
|
|
||||||
expect(res.body.correction.correctedTechStep.isPlaceholder).to.equal(true);
|
|
||||||
expect(res.body.correction.correctedTechStep.displayName).to.equal("Flamber au chalumeau");
|
|
||||||
// ...and the step's live sequence carries it straight away.
|
|
||||||
expect(res.body.techSteps).to.have.length(1);
|
|
||||||
expect(res.body.techSteps[0].techStep.isPlaceholder).to.equal(true);
|
|
||||||
expect(res.body.techSteps[0].techStep.displayName).to.equal("Flamber au chalumeau");
|
|
||||||
expect(res.body.techSteps[0].source).to.equal("manual");
|
|
||||||
|
|
||||||
const row = await prisma.techStep.findUniqueOrThrow({
|
|
||||||
where: { id: res.body.techSteps[0].techStep.id },
|
|
||||||
});
|
|
||||||
expect(row.isPlaceholder).to.equal(true);
|
|
||||||
expect(row.key).to.match(/^placeholder:/);
|
|
||||||
expect(row.createdById).to.equal(profileId);
|
|
||||||
expect(row.createdAt).to.be.an.instanceOf(Date);
|
|
||||||
expect(row.reviewedAt).to.equal(null);
|
|
||||||
// It never leaks into the browsable catalog.
|
|
||||||
const reference = await agent.get("/reference/tech-steps");
|
|
||||||
expect(
|
|
||||||
reference.body.some(
|
|
||||||
(t: { displayName?: string }) => t.displayName === "Flamber au chalumeau",
|
|
||||||
),
|
|
||||||
).to.equal(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects sending both correctedTechStepId and correctedTechStepName with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: await techStepId("simmer"),
|
|
||||||
correctedTechStepName: "Autre chose",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
@ -200,23 +145,13 @@ describe("Recipe tech-step corrections", () => {
|
||||||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.correction.previousTechStep).to.deep.equal({
|
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||||
id: simmerId,
|
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
||||||
key: "simmer",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
|
||||||
id: boilId,
|
|
||||||
key: "boil",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
// Still exactly one entry — the relabel updated the existing row
|
// Still exactly one entry — the relabel updated the existing row
|
||||||
// rather than adding a second one alongside it.
|
// rather than adding a second one alongside it.
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null },
|
techStep: { id: boilId, key: "boil" },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -364,7 +299,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
techStep: { id: simmerId, key: "simmer" },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -600,7 +535,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
|
techStep: { id: preheatId, key: "preheat" },
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 11,
|
end: 11,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -786,18 +721,8 @@ describe("Recipe tech-step corrections", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(2);
|
expect(res.body).to.have.length(2);
|
||||||
expect(res.body[0].correctedTechStep).to.deep.equal({
|
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
||||||
id: boilId,
|
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
||||||
key: "boil",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
expect(res.body[1].correctedTechStep).to.deep.equal({
|
|
||||||
id: simmerId,
|
|
||||||
key: "simmer",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when nothing has been submitted yet", async () => {
|
it("returns an empty list when nothing has been submitted yet", async () => {
|
||||||
|
|
|
||||||
|
|
@ -148,30 +148,7 @@ describe("Reference data", () => {
|
||||||
// number — this catalog has grown since (26 -> 74) and will again.
|
// number — this catalog has grown since (26 -> 74) and will again.
|
||||||
expect(res.body).to.have.length(TECH_STEPS.length);
|
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key", "isPlaceholder", "displayName"]);
|
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||||
expect(res.body.every((t: { isPlaceholder: boolean }) => t.isPlaceholder === false)).to.equal(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes placeholder techniques users proposed while correcting a step (issue #130)", async () => {
|
|
||||||
await prisma.techStep.create({
|
|
||||||
data: {
|
|
||||||
key: "placeholder:test-1",
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: "Flamber au chalumeau",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(TECH_STEPS.length);
|
|
||||||
expect(
|
|
||||||
res.body.some(
|
|
||||||
(t: { displayName: string | null }) => t.displayName === "Flamber au chalumeau",
|
|
||||||
),
|
|
||||||
).to.equal(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders techniques alphabetically by key", async () => {
|
it("orders techniques alphabetically by key", async () => {
|
||||||
|
|
|
||||||
|
|
@ -214,76 +214,6 @@ describe("Shopping list", () => {
|
||||||
expect(res.body.items[0].quantity).to.equal(300);
|
expect(res.body.items[0].quantity).to.equal(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists a « selon le goût » seasoning ingredient once, with no quantity, never summed (issue #128)", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
const houseId: number = houseRes.body.id;
|
|
||||||
const authorId: number = houseRes.body.adminId;
|
|
||||||
|
|
||||||
const tomatoId = await ingredientId("tomato");
|
|
||||||
const saltId = await ingredientId("salt");
|
|
||||||
const gramId = await unitId("gram");
|
|
||||||
|
|
||||||
// 100g tomato + salt "selon le goût" (quantity/unit null), planned
|
|
||||||
// twice at 4 portions each (recipe yields 2): tomato scales to 400g,
|
|
||||||
// salt stays a single unquantified line.
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Tomates au sel",
|
|
||||||
authorId,
|
|
||||||
portions: 2,
|
|
||||||
ingredients: {
|
|
||||||
create: [
|
|
||||||
{ ingredientId: tomatoId, quantity: 100, unitId: gramId },
|
|
||||||
{ ingredientId: saltId, quantity: null, unitId: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const planning = await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
|
||||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.planningItem.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipeId: recipe.id,
|
|
||||||
portions: 4,
|
|
||||||
portionsRequested: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "mercredi",
|
|
||||||
meal: "diner",
|
|
||||||
recipeId: recipe.id,
|
|
||||||
portions: 4,
|
|
||||||
portionsRequested: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
const salt = res.body.items.filter(
|
|
||||||
(item: { ingredient: { key: string } }) => item.ingredient.key === "salt",
|
|
||||||
);
|
|
||||||
expect(salt).to.have.length(1);
|
|
||||||
expect(salt[0].quantity).to.equal(null);
|
|
||||||
expect(salt[0].unit).to.equal(null);
|
|
||||||
const tomato = res.body.items.find(
|
|
||||||
(item: { ingredient: { key: string } }) => item.ingredient.key === "tomato",
|
|
||||||
);
|
|
||||||
expect(tomato.quantity).to.equal(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
|
||||||
|
|
@ -339,12 +339,7 @@ describe("Sources", () => {
|
||||||
const [step] = res.body.steps;
|
const [step] = res.body.steps;
|
||||||
expect(step.description).to.equal("Chop the onions finely");
|
expect(step.description).to.equal("Chop the onions finely");
|
||||||
expect(step.techSteps).to.have.length(1);
|
expect(step.techSteps).to.have.length(1);
|
||||||
expect(step.techSteps[0].techStep).to.deep.equal({
|
expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" });
|
||||||
id: chop.id,
|
|
||||||
key: "chop",
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
|
||||||
expect(
|
expect(
|
||||||
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
|
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
|
||||||
).to.equal("chop");
|
).to.equal("chop");
|
||||||
|
|
@ -387,8 +382,6 @@ describe("Sources", () => {
|
||||||
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
|
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
|
||||||
id: simmer.id,
|
id: simmer.id,
|
||||||
key: "simmer",
|
key: "simmer",
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -433,6 +426,7 @@ describe("Sources", () => {
|
||||||
return {
|
return {
|
||||||
name: "Fake recipe 1 (revue)",
|
name: "Fake recipe 1 (revue)",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
|
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,8 @@ android {
|
||||||
applicationId "fr.kyuno.batchcooking"
|
applicationId "fr.kyuno.batchcooking"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
// Surchargeables depuis la CI via `-PandroidVersionCode=... -PandroidVersionName=...`
|
versionCode 1
|
||||||
// (release.yml, dérivés du tag `vX.Y.Z` poussé) — la valeur par
|
versionName "1.0"
|
||||||
// défaut ci-dessous ne sert qu'aux builds locaux/CI qui ne passent
|
|
||||||
// pas ces propriétés (Android Studio, le job build-android de
|
|
||||||
// ci.yml sur un simple push main), jamais à une vraie release.
|
|
||||||
versionCode (project.hasProperty("androidVersionCode") ? (project.androidVersionCode as Integer) : 1)
|
|
||||||
versionName (project.hasProperty("androidVersionName") ? project.androidVersionName : "1.0")
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,6 @@
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Custom scheme for the web app's "open the installed app"
|
|
||||||
redirect (see apps/web/src/lib/mobile-app-redirect.ts) —
|
|
||||||
additive, does not touch the launcher filter above. No
|
|
||||||
android:host/android:path restriction, so any
|
|
||||||
batchcooking://... URL matches. -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="batchcooking" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Dark-mode overrides for the native colour resources in
|
|
||||||
../values/colors.xml. Without this file, AppTheme.NoActionBar's DayNight
|
|
||||||
parent still resolved @color/colorPrimaryDark / @color/colorBackground to
|
|
||||||
their light values under a dark system setting: the status-bar band and
|
|
||||||
the pre-WebView window (cold-start flash + splash ground) stayed on the
|
|
||||||
light "porcelaine" palette while the rest of the phone, and the app's own
|
|
||||||
WebView content, went dark (issue #104).
|
|
||||||
|
|
||||||
Keep the hex values in sync with the dark-mode colour tokens in
|
|
||||||
apps/web/src/styles/_theme.scss (the prefers-color-scheme: dark block).
|
|
||||||
Note: XML comments here must not contain a double hyphen, so the theme
|
|
||||||
tokens are named "color-x", not with their CSS custom-property prefix.
|
|
||||||
-->
|
|
||||||
<resources>
|
|
||||||
<!-- token color-primary (dark). -->
|
|
||||||
<color name="colorPrimary">#5FAE7E</color>
|
|
||||||
<!-- Status-bar band + pre-WebView window in dark mode: the dark
|
|
||||||
color-background, so the band is seamless with the dark app below
|
|
||||||
it rather than a stripe of light green. windowLightStatusBar stays
|
|
||||||
false (light icons), correct against this near-black just as it is
|
|
||||||
against the light-mode dark-basil. -->
|
|
||||||
<color name="colorPrimaryDark">#14181A</color>
|
|
||||||
<!-- token color-accent (dark). -->
|
|
||||||
<color name="colorAccent">#EA7A48</color>
|
|
||||||
<!-- token color-background (dark), backs the splash and the window
|
|
||||||
while the WebView is still blank. -->
|
|
||||||
<color name="colorBackground">#14181A</color>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { Dialog } from "../../src/components/ui/Dialog";
|
|
||||||
import { PHONE_VIEWPORT } from "../support/viewports";
|
|
||||||
|
|
||||||
// First component test for Dialog — until now it was only exercised
|
|
||||||
// indirectly through RecipePickerDialog's own e2e coverage. Added here
|
|
||||||
// specifically to prove `cy.setViewport()` (mobile/tablet UI chantier,
|
|
||||||
// ticket #83) works correctly in component-test mode too, not just e2e —
|
|
||||||
// and along the way, covers the phone full-screen treatment (ticket #91)
|
|
||||||
// that had no test of its own yet.
|
|
||||||
|
|
||||||
describe("Dialog — phone full-screen treatment", () => {
|
|
||||||
it("is a centered, rounded panel with room around it at a desktop viewport", () => {
|
|
||||||
// Component testing's own default viewport (unlike e2e's) is already
|
|
||||||
// phone-sized — an explicit wide viewport here, not just "whatever the
|
|
||||||
// default is", is what actually makes this the desktop case.
|
|
||||||
const desktopWidth = 1280;
|
|
||||||
cy.viewport(desktopWidth, 800);
|
|
||||||
cy.mount(
|
|
||||||
<Dialog title="Titre" onClose={() => {}}>
|
|
||||||
Contenu
|
|
||||||
</Dialog>,
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get(".dialog-panel").should(($el) => {
|
|
||||||
const style = getComputedStyle($el[0]);
|
|
||||||
expect(style.borderRadius).to.not.equal("0px");
|
|
||||||
expect($el[0].getBoundingClientRect().width).to.be.lessThan(desktopWidth);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("goes edge-to-edge, with no rounding, at a phone viewport", () => {
|
|
||||||
cy.setViewport("phone");
|
|
||||||
cy.mount(
|
|
||||||
<Dialog title="Titre" onClose={() => {}}>
|
|
||||||
Contenu
|
|
||||||
</Dialog>,
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get(".dialog-panel").should(($el) => {
|
|
||||||
const style = getComputedStyle($el[0]);
|
|
||||||
expect(style.borderRadius).to.equal("0px");
|
|
||||||
const rect = $el[0].getBoundingClientRect();
|
|
||||||
expect(rect.width).to.be.closeTo(PHONE_VIEWPORT.width, 1);
|
|
||||||
expect(rect.height).to.be.closeTo(PHONE_VIEWPORT.height, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import "../../src/i18n/i18n";
|
|
||||||
import { PasswordInput } from "../../src/components/ui/PasswordInput";
|
|
||||||
|
|
||||||
// Mounts PasswordInput in isolation (no form, no router, no backend) — the
|
|
||||||
// same "generic component test" posture as CheckboxOption.cy.tsx. Needs
|
|
||||||
// `../../src/i18n/i18n` imported for its side effect: unlike Checkbox/Radio,
|
|
||||||
// this component calls `useTranslation()` for the eye button's aria-label,
|
|
||||||
// and with no <I18nextProvider> in the tree it falls back to that default
|
|
||||||
// instance (see TechStepCorrectionPopover.cy.tsx for the same note).
|
|
||||||
|
|
||||||
const SHOW_LABEL = "Afficher le mot de passe";
|
|
||||||
const HIDE_LABEL = "Masquer le mot de passe";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controlled wrapper — PasswordInput holds no text state of its own, so a
|
|
||||||
* real caller (LoginPage, SignupPage) always owns the value. `onSpy` lets a
|
|
||||||
* test assert on what each change reports.
|
|
||||||
*/
|
|
||||||
function Wrapper({ onSpy }: { onSpy?: (value: string) => void }) {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
return (
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
value={value}
|
|
||||||
onChange={(next) => {
|
|
||||||
setValue(next);
|
|
||||||
onSpy?.(next);
|
|
||||||
}}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PasswordInput", () => {
|
|
||||||
it("renders a masked password field by default", () => {
|
|
||||||
cy.mount(<Wrapper />);
|
|
||||||
cy.get("input#password").should("have.attr", "type", "password");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forwards id and autoComplete to the native input", () => {
|
|
||||||
cy.mount(<Wrapper />);
|
|
||||||
cy.get("input#password").should("have.attr", "autocomplete", "current-password");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reveals the text when the eye is clicked, then re-masks it on a second click", () => {
|
|
||||||
cy.mount(<Wrapper />);
|
|
||||||
cy.get("input#password").type("hunter2").should("have.attr", "type", "password");
|
|
||||||
|
|
||||||
cy.get("button.password-input__toggle").click();
|
|
||||||
cy.get("input#password").should("have.attr", "type", "text").and("have.value", "hunter2");
|
|
||||||
|
|
||||||
cy.get("button.password-input__toggle").click();
|
|
||||||
cy.get("input#password").should("have.attr", "type", "password").and("have.value", "hunter2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("labels the toggle with the action the next click performs, and exposes state via aria-pressed", () => {
|
|
||||||
cy.mount(<Wrapper />);
|
|
||||||
cy.get("button.password-input__toggle")
|
|
||||||
.should("have.attr", "aria-label", SHOW_LABEL)
|
|
||||||
.and("have.attr", "aria-pressed", "false");
|
|
||||||
|
|
||||||
cy.get("button.password-input__toggle").click();
|
|
||||||
cy.get("button.password-input__toggle")
|
|
||||||
.should("have.attr", "aria-label", HIDE_LABEL)
|
|
||||||
.and("have.attr", "aria-pressed", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a controlled input — it shows the parent's value and reports every change", () => {
|
|
||||||
const onSpy = cy.stub().as("onSpy");
|
|
||||||
cy.mount(<Wrapper onSpy={onSpy} />);
|
|
||||||
|
|
||||||
cy.get("input#password").type("abc");
|
|
||||||
cy.get("input#password").should("have.value", "abc");
|
|
||||||
cy.get("@onSpy").should("have.been.calledWith", "abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not submit a surrounding form when the eye is clicked — the toggle is a plain button", () => {
|
|
||||||
const onSubmit = cy.stub().as("onSubmit");
|
|
||||||
cy.mount(
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Wrapper />
|
|
||||||
<button type="submit">Go</button>
|
|
||||||
</form>,
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get("button.password-input__toggle").click();
|
|
||||||
cy.get("@onSubmit").should("not.have.been.called");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts hidden again on a fresh mount — visibility is never persisted", () => {
|
|
||||||
cy.mount(<Wrapper />);
|
|
||||||
cy.get("button.password-input__toggle").click();
|
|
||||||
cy.get("input#password").should("have.attr", "type", "text");
|
|
||||||
|
|
||||||
// A brand-new mount, as if the visitor navigated back to the page later.
|
|
||||||
cy.mount(<Wrapper />);
|
|
||||||
cy.get("input#password").should("have.attr", "type", "password");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -194,41 +194,6 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.get("@onSubmitted").should("have.been.calledOnce");
|
cy.get("@onSubmitted").should("have.been.calledOnce");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("proposes a technique the catalog is missing via the 'pas dans la liste' escape hatch (issue #130)", () => {
|
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
||||||
statusCode: 201,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStep: null,
|
|
||||||
correctedTechStep: {
|
|
||||||
id: 99,
|
|
||||||
key: "placeholder:x",
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: "Flamber au chalumeau",
|
|
||||||
},
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}).as("submitCorrection");
|
|
||||||
cy.mount(<Harness />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains("button", "Ma technique n'est pas dans la liste").click();
|
|
||||||
cy.get(".tech-step-correction-popover__missing-technique input").type("Flamber au chalumeau");
|
|
||||||
cy.contains(".tech-step-correction-popover__missing-technique button", "Ajouter").click();
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Flamber au chalumeau");
|
|
||||||
cy.contains("button", "Valider").should("not.be.disabled").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStepId: null,
|
|
||||||
correctedTechStepName: "Flamber au chalumeau",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
|
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import {
|
|
||||||
APP_OPEN_TIMEOUT_MS,
|
|
||||||
attemptOpenNativeApp,
|
|
||||||
isAndroidPhoneBrowser,
|
|
||||||
STAY_ON_WEB_PREFERENCE_KEY,
|
|
||||||
} from "../../src/lib/mobile-app-redirect";
|
|
||||||
|
|
||||||
// Pure-ish logic (timers/localStorage/document.hidden, no React component to
|
|
||||||
// mount) — same posture as version-compare.cy.tsx: reuses the component-test
|
|
||||||
// runner purely for `expect`/`cy.clock()`, no `cy.mount`. `.cy.tsx` (not
|
|
||||||
// `.cy.ts`) only because that's what cypress.config.ts's component
|
|
||||||
// `specPattern` looks for.
|
|
||||||
|
|
||||||
function setUserAgent(ua: string) {
|
|
||||||
Object.defineProperty(window.navigator, "userAgent", { value: ua, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDocumentHidden(hidden: boolean) {
|
|
||||||
Object.defineProperty(document, "hidden", { value: hidden, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isAndroidPhoneBrowser", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
setUserAgent(window.navigator.userAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true for a real Android phone Chrome UA", () => {
|
|
||||||
setUserAgent(
|
|
||||||
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36",
|
|
||||||
);
|
|
||||||
expect(isAndroidPhoneBrowser()).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for an Android tablet UA (Android without Mobile)", () => {
|
|
||||||
setUserAgent(
|
|
||||||
"Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
|
||||||
);
|
|
||||||
expect(isAndroidPhoneBrowser()).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for a desktop UA", () => {
|
|
||||||
setUserAgent(
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
|
||||||
);
|
|
||||||
expect(isAndroidPhoneBrowser()).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("attemptOpenNativeApp", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
|
|
||||||
setDocumentHidden(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
localStorage.removeItem(STAY_ON_WEB_PREFERENCE_KEY);
|
|
||||||
setDocumentHidden(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onFallback immediately, with no delay, when the visitor opted to stay on web", () => {
|
|
||||||
localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, "true");
|
|
||||||
const onFallback = cy.stub().as("onFallback");
|
|
||||||
|
|
||||||
attemptOpenNativeApp({ onFallback });
|
|
||||||
|
|
||||||
cy.get("@onFallback").should("have.been.calledOnce");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onFallback once the delay elapses if the page never got hidden (app not installed)", () => {
|
|
||||||
cy.clock();
|
|
||||||
const onFallback = cy.stub().as("onFallback");
|
|
||||||
|
|
||||||
// `openAppUrl` stubbed to a no-op — real navigation to the custom
|
|
||||||
// scheme is only meaningful in a real browser with the app installed
|
|
||||||
// or not; here only the timer/visibility logic around it is under
|
|
||||||
// test (see the option's own doc comment in mobile-app-redirect.ts).
|
|
||||||
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
|
|
||||||
cy.get("@onFallback").should("not.have.been.called");
|
|
||||||
|
|
||||||
cy.tick(APP_OPEN_TIMEOUT_MS);
|
|
||||||
cy.get("@onFallback").should("have.been.calledOnce");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never calls onFallback if the page is hidden before the delay elapses (handoff succeeded)", () => {
|
|
||||||
cy.clock();
|
|
||||||
const onFallback = cy.stub().as("onFallback");
|
|
||||||
|
|
||||||
attemptOpenNativeApp({ onFallback, delayMs: APP_OPEN_TIMEOUT_MS, openAppUrl: () => {} });
|
|
||||||
|
|
||||||
cy.then(() => {
|
|
||||||
setDocumentHidden(true);
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.tick(APP_OPEN_TIMEOUT_MS);
|
|
||||||
cy.get("@onFallback").should("not.have.been.called");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -78,45 +78,6 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to the Techniques kind and lists proposed missing techniques (issue #130)", () => {
|
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/admin/api/catalog/tech-step-placeholders*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
displayName: "Flamber au chalumeau",
|
|
||||||
createdAt: "2026-09-01T10:00:00.000Z",
|
|
||||||
reviewedAt: null,
|
|
||||||
recipeCount: 2,
|
|
||||||
sampleRecipes: [
|
|
||||||
{ id: 1, name: "Crème brûlée" },
|
|
||||||
{ id: 2, name: "Tarte flambée" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).as("getTechStepPlaceholders");
|
|
||||||
cy.intercept("PATCH", "**/admin/api/catalog/tech-step-placeholders/mark-reviewed", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { reviewed: 1 },
|
|
||||||
}).as("markTechStepReviewed");
|
|
||||||
|
|
||||||
cy.visit("/admin/catalogue");
|
|
||||||
cy.contains(".catalog-kinds button", "Techniques").click();
|
|
||||||
cy.wait("@getTechStepPlaceholders");
|
|
||||||
|
|
||||||
cy.contains(".catalog-card", "Flamber au chalumeau")
|
|
||||||
.should("contain.text", "2 recette")
|
|
||||||
.and("contain.text", "Crème brûlée");
|
|
||||||
|
|
||||||
cy.contains(".catalog-card", "Flamber au chalumeau")
|
|
||||||
.contains("button", "Marquer comme traité")
|
|
||||||
.click();
|
|
||||||
cy.wait("@markTechStepReviewed")
|
|
||||||
.its("request.body")
|
|
||||||
.should("deep.equal", { techStepIds: [5] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to the reviewed archive tab", () => {
|
it("switches to the reviewed archive tab", () => {
|
||||||
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
||||||
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ describe("Admin layout", () => {
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
||||||
cy.contains("nav a", "Catalogue").click();
|
cy.contains("nav a", "Catalogue").click();
|
||||||
cy.url().should("include", "/admin/catalogue");
|
cy.url().should("include", "/admin/catalogue");
|
||||||
cy.contains("h1", "Éléments hors-catalogue").should("be.visible");
|
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
|
||||||
|
|
||||||
cy.contains("nav a", "Tableau de bord").click();
|
cy.contains("nav a", "Tableau de bord").click();
|
||||||
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
|
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend (see
|
|
||||||
// .github/workflows/ci.yml); apps/api's Mocha suite covers
|
|
||||||
// `GET /admin/api/contact` and the Forgejo routes against a real database.
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Matches the list endpoint (`/contact`, `/contact?type=…`) but NOT the
|
|
||||||
// nested `/contact/forgejo/status` or `/contact/:id/forgejo-issue`.
|
|
||||||
const LIST_URL = /\/admin\/api\/contact(\?|$)/;
|
|
||||||
|
|
||||||
function messages() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: "bug",
|
|
||||||
message: "Le bouton Envoyer ne réagit pas sur la page planning.",
|
|
||||||
pageUrl: "/",
|
|
||||||
userAgent: "Mozilla/5.0 (Linux; Android 14) BatchCookingApp/0.2.1",
|
|
||||||
appVersion: "0.2.1",
|
|
||||||
createdAt: "2026-09-07T10:00:00.000Z",
|
|
||||||
author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" },
|
|
||||||
forgejoIssueNumber: null,
|
|
||||||
forgejoIssueUrl: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: "suggestion",
|
|
||||||
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
|
|
||||||
pageUrl: "/liste-de-courses",
|
|
||||||
userAgent: null,
|
|
||||||
appVersion: null,
|
|
||||||
createdAt: "2026-09-06T08:30:00.000Z",
|
|
||||||
author: null,
|
|
||||||
forgejoIssueNumber: null,
|
|
||||||
forgejoIssueUrl: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin contact messages (/admin/messages)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 1000);
|
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
// Default: Forgejo integration off — individual tests override.
|
|
||||||
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { enabled: false },
|
|
||||||
}).as("forgejoStatus");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists the messages read-only: sender or « Compte supprimé », type badge, message", () => {
|
|
||||||
cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
|
|
||||||
|
|
||||||
cy.visit("/admin/messages");
|
|
||||||
cy.wait("@getMessages");
|
|
||||||
|
|
||||||
cy.get(".messages-table tbody tr").should("have.length", 2);
|
|
||||||
cy.contains(".messages-table tr", "page planning").should("contain.text", "alice@example.com");
|
|
||||||
cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé");
|
|
||||||
// Integration off ⇒ no create button, and the "not configured" note shows.
|
|
||||||
cy.get(".messages-table__ticket-button").should("not.exist");
|
|
||||||
cy.contains(".messages-forgejo-note", "non configurée").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by type through the query param", () => {
|
|
||||||
cy.intercept("GET", "**/admin/api/contact", { statusCode: 200, body: messages() }).as("all");
|
|
||||||
cy.intercept("GET", "**/admin/api/contact?type=suggestion", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [messages()[1]],
|
|
||||||
}).as("filtered");
|
|
||||||
|
|
||||||
cy.visit("/admin/messages");
|
|
||||||
cy.wait("@all");
|
|
||||||
|
|
||||||
cy.get(".messages-filters select").select("Suggestion");
|
|
||||||
cy.wait("@filtered");
|
|
||||||
cy.get(".messages-table tbody tr").should("have.length", 1);
|
|
||||||
cy.contains(".messages-table tr", "mode hors-ligne").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a Forgejo issue from a message through the pre-filled modal", () => {
|
|
||||||
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { enabled: true },
|
|
||||||
}).as("forgejoStatus");
|
|
||||||
cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
|
|
||||||
cy.intercept("POST", "**/admin/api/contact/2/forgejo-issue", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
...messages()[0],
|
|
||||||
forgejoIssueNumber: 101,
|
|
||||||
forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/101",
|
|
||||||
},
|
|
||||||
}).as("createIssue");
|
|
||||||
|
|
||||||
cy.visit("/admin/messages");
|
|
||||||
cy.wait("@getMessages");
|
|
||||||
|
|
||||||
cy.contains(".messages-table tr", "page planning")
|
|
||||||
.find(".messages-table__ticket-button")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Modal opens pre-filled from the message.
|
|
||||||
cy.get("#forgejoIssueTitle").should(
|
|
||||||
"have.value",
|
|
||||||
"[Bug] Le bouton Envoyer ne réagit pas sur la page planning.",
|
|
||||||
);
|
|
||||||
cy.get("#forgejoIssueBody").should("contain.value", "Signalé par");
|
|
||||||
|
|
||||||
cy.contains(".dialog-panel__footer button", "Créer le ticket").click();
|
|
||||||
cy.wait("@createIssue").its("request.body").should("have.keys", ["title", "body"]);
|
|
||||||
|
|
||||||
// Modal closed, the row now shows the link instead of the button.
|
|
||||||
cy.get("#forgejoIssueTitle").should("not.exist");
|
|
||||||
cy.contains(".messages-table tr", "page planning")
|
|
||||||
.find("a")
|
|
||||||
.should("have.attr", "href", "https://git.example/kyuno/batchCooking/issues/101")
|
|
||||||
.and("contain.text", "#101");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the existing link and no create button for an already-linked message", () => {
|
|
||||||
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { enabled: true },
|
|
||||||
});
|
|
||||||
const linked = messages().map((m) =>
|
|
||||||
m.id === 2
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
forgejoIssueNumber: 55,
|
|
||||||
forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/55",
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
cy.intercept("GET", LIST_URL, { statusCode: 200, body: linked }).as("getMessages");
|
|
||||||
|
|
||||||
cy.visit("/admin/messages");
|
|
||||||
cy.wait("@getMessages");
|
|
||||||
|
|
||||||
cy.contains(".messages-table tr", "page planning").within(() => {
|
|
||||||
cy.contains("a", "#55").should(
|
|
||||||
"have.attr",
|
|
||||||
"href",
|
|
||||||
"https://git.example/kyuno/batchCooking/issues/55",
|
|
||||||
);
|
|
||||||
cy.get(".messages-table__ticket-button").should("not.exist");
|
|
||||||
});
|
|
||||||
// The still-unlinked suggestion row keeps its button.
|
|
||||||
cy.contains(".messages-table tr", "mode hors-ligne")
|
|
||||||
.find(".messages-table__ticket-button")
|
|
||||||
.should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend. Covers the
|
|
||||||
// `/admin/utilisateurs` page: the account list, the create dialog, the
|
|
||||||
// one-time temporary-password reveal, and the duplicate-email error.
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
function users() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
householdName: "Foyer Martin",
|
|
||||||
createdAt: "2026-09-09T10:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
firstName: "Bob",
|
|
||||||
lastName: "Durand",
|
|
||||||
email: "bob@example.com",
|
|
||||||
householdName: null,
|
|
||||||
createdAt: "2026-09-08T08:00:00.000Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin — utilisateurs", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 900);
|
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
cy.intercept("GET", "**/admin/api/users", { statusCode: 200, body: users() }).as("list");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists every account with its household (or a dash when none)", () => {
|
|
||||||
cy.visit("/admin/utilisateurs");
|
|
||||||
cy.wait("@list");
|
|
||||||
|
|
||||||
cy.contains(".users-table tr", "alice@example.com").should("contain.text", "Foyer Martin");
|
|
||||||
cy.contains(".users-table tr", "bob@example.com").should("contain.text", "—");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates an account and reveals the one-time temporary password", () => {
|
|
||||||
cy.intercept("POST", "**/admin/api/users", (req) => {
|
|
||||||
req.reply({
|
|
||||||
statusCode: 201,
|
|
||||||
body: {
|
|
||||||
user: {
|
|
||||||
id: 4,
|
|
||||||
firstName: req.body.firstName,
|
|
||||||
lastName: req.body.lastName,
|
|
||||||
email: req.body.email,
|
|
||||||
householdName: null,
|
|
||||||
createdAt: "2026-09-10T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
temporaryPassword: "Tmp4kR7qWzP2xY9n",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}).as("create");
|
|
||||||
|
|
||||||
cy.visit("/admin/utilisateurs");
|
|
||||||
cy.wait("@list");
|
|
||||||
cy.contains("button", "Nouvel utilisateur").click();
|
|
||||||
|
|
||||||
cy.get('[data-testid="create-user-form"]').within(() => {
|
|
||||||
cy.get("#newUserFirstName").type("Chloé");
|
|
||||||
cy.get("#newUserLastName").type("Petit");
|
|
||||||
cy.get("#newUserEmail").type("chloe@example.com");
|
|
||||||
});
|
|
||||||
cy.contains(".dialog-panel__footer button", "Créer le compte").click();
|
|
||||||
|
|
||||||
cy.wait("@create").its("request.body").should("deep.equal", {
|
|
||||||
firstName: "Chloé",
|
|
||||||
lastName: "Petit",
|
|
||||||
email: "chloe@example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.get('[data-testid="temp-password-reveal"]')
|
|
||||||
.find("#tempPassword")
|
|
||||||
.should("have.value", "Tmp4kR7qWzP2xY9n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the localized error when the email is already in use", () => {
|
|
||||||
cy.intercept("POST", "**/admin/api/users", {
|
|
||||||
statusCode: 409,
|
|
||||||
body: { code: 4001, message: "Email already in use" },
|
|
||||||
}).as("createDup");
|
|
||||||
|
|
||||||
cy.visit("/admin/utilisateurs");
|
|
||||||
cy.wait("@list");
|
|
||||||
cy.contains("button", "Nouvel utilisateur").click();
|
|
||||||
|
|
||||||
cy.get('[data-testid="create-user-form"]').within(() => {
|
|
||||||
cy.get("#newUserFirstName").type("Alice");
|
|
||||||
cy.get("#newUserLastName").type("Martin");
|
|
||||||
cy.get("#newUserEmail").type("alice@example.com");
|
|
||||||
});
|
|
||||||
cy.contains(".dialog-panel__footer button", "Créer le compte").click();
|
|
||||||
|
|
||||||
cy.wait("@createDup");
|
|
||||||
cy.get('[data-testid="create-user-form"]').should("contain.text", "Cet email est déjà utilisé");
|
|
||||||
// Still on the form — no password reveal.
|
|
||||||
cy.get('[data-testid="temp-password-reveal"]').should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
||||||
// .github/workflows/ci.yml).
|
|
||||||
//
|
|
||||||
// Issue #107 — "Paramètres d'application" is packaged-app only now. Its
|
|
||||||
// content (installed version + update check) is inert on the web, and the
|
|
||||||
// one web-relevant control it carried (the "stay on web" redirect toggle)
|
|
||||||
// moved to `/telecharger` (see download-page.cy.ts). Cypress always runs as
|
|
||||||
// a plain web browser (`Capacitor.isNativePlatform()` is never true here),
|
|
||||||
// so the page itself has no seam to reach — same untested-on-purpose
|
|
||||||
// posture already accepted for `useAppUpdateCheck` / `UpdateBanner`. This
|
|
||||||
// spec now only covers the web-side gating.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: null,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("App settings — hidden on the web (#107)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is absent from the Paramètres menu", () => {
|
|
||||||
cy.visit("/");
|
|
||||||
cy.contains("button", "Paramètres").click();
|
|
||||||
cy.contains("a", "Paramètres d'application").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects /parametres/application to /parametres/compte instead of an orphaned page", () => {
|
|
||||||
cy.visit("/parametres/application");
|
|
||||||
cy.url().should("include", "/parametres/compte");
|
|
||||||
cy.contains("h1", "Paramètres d'application").should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — this job runs no live backend (see
|
|
||||||
// .github/workflows/ci.yml). The signup/login *journeys* live in
|
|
||||||
// auth.feature; this plain layout spec covers only the show/hide password
|
|
||||||
// toggle (PasswordInput) wired into both auth pages — "a section of an
|
|
||||||
// existing page" in specs/dev-conventions.md's test taxonomy, so a plain
|
|
||||||
// `.cy.ts`, not a Gherkin scenario.
|
|
||||||
|
|
||||||
describe("Auth forms — password show/hide toggle", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Both pages sit behind RedirectIfAuthenticated; a 401 on the session
|
|
||||||
// check keeps the visitor logged out so the form actually renders.
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { label, path } of [
|
|
||||||
{ label: "login", path: "/login" },
|
|
||||||
{ label: "signup", path: "/signup" },
|
|
||||||
]) {
|
|
||||||
it(`reveals then re-masks the password field on ${label}`, () => {
|
|
||||||
cy.visit(path);
|
|
||||||
|
|
||||||
// Starts masked, with the toggle offering to reveal it.
|
|
||||||
cy.get("#password").should("have.attr", "type", "password");
|
|
||||||
cy.get('[aria-label="Afficher le mot de passe"]')
|
|
||||||
.should("be.visible")
|
|
||||||
.and("have.attr", "aria-pressed", "false");
|
|
||||||
|
|
||||||
// Reveal.
|
|
||||||
cy.get('[aria-label="Afficher le mot de passe"]').click();
|
|
||||||
cy.get("#password").should("have.attr", "type", "text");
|
|
||||||
cy.get('[aria-label="Masquer le mot de passe"]').should("have.attr", "aria-pressed", "true");
|
|
||||||
|
|
||||||
// Re-mask.
|
|
||||||
cy.get('[aria-label="Masquer le mot de passe"]').click();
|
|
||||||
cy.get("#password").should("have.attr", "type", "password");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("does not submit the login form when the eye is clicked", () => {
|
|
||||||
cy.intercept("POST", "**/auth/login", cy.spy().as("loginRequest"));
|
|
||||||
cy.visit("/login");
|
|
||||||
|
|
||||||
cy.get("#email").type("alice@example.com");
|
|
||||||
cy.get("#password").type("correct-horse-battery-staple");
|
|
||||||
cy.get('[aria-label="Afficher le mot de passe"]').click();
|
|
||||||
|
|
||||||
cy.get("#password").should("have.attr", "type", "text");
|
|
||||||
cy.get("@loginRequest").should("not.have.been.called");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -51,7 +51,7 @@ Feature: Signup and login
|
||||||
Then the login request should have been made
|
Then the login request should have been made
|
||||||
And I should see "Email ou mot de passe incorrect"
|
And I should see "Email ou mot de passe incorrect"
|
||||||
|
|
||||||
Scenario: Logging out returns to the landing page
|
Scenario: Logging out returns to the login page
|
||||||
Given I am signed in as "Alice" "Martin"
|
Given I am signed in as "Alice" "Martin"
|
||||||
And my household id is 1
|
And my household id is 1
|
||||||
And the planning request returns nothing
|
And the planning request returns nothing
|
||||||
|
|
@ -60,4 +60,4 @@ Feature: Signup and login
|
||||||
And I open the account menu
|
And I open the account menu
|
||||||
And I click the button "Se déconnecter"
|
And I click the button "Se déconnecter"
|
||||||
Then the logout request should have been made
|
Then the logout request should have been made
|
||||||
And the URL should be the home page
|
And the URL should include "/login"
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
||||||
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
|
|
||||||
// `POST /contact` behavior against a real database. The Gherkin journey
|
|
||||||
// (fill + send + confirmation) lives in contact.feature — this file only
|
|
||||||
// covers the page's initial render and its guards.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: null,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Contact page (/parametres/contact)", () => {
|
|
||||||
it("redirects to /login when signed out (RequireAuth)", () => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
||||||
|
|
||||||
cy.visit("/parametres/contact");
|
|
||||||
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("signed in", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the form: a type select defaulting to Bug, a message box, a submit button", () => {
|
|
||||||
cy.visit("/parametres/contact");
|
|
||||||
|
|
||||||
cy.contains("h1", "Nous contacter").should("be.visible");
|
|
||||||
cy.get("#contactType option").should("have.length", 3);
|
|
||||||
cy.get("#contactType").should("have.value", "bug");
|
|
||||||
cy.get("#contactMessage").should("be.visible");
|
|
||||||
cy.contains("button", "Envoyer").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a translated error label, never the raw server message, when the submit fails", () => {
|
|
||||||
cy.intercept("POST", "**/contact", {
|
|
||||||
statusCode: 500,
|
|
||||||
body: { code: 5000, message: "raw internal boom" },
|
|
||||||
}).as("submitContact");
|
|
||||||
|
|
||||||
cy.visit("/parametres/contact");
|
|
||||||
cy.get("#contactMessage").type(
|
|
||||||
"Ceci est un message de test suffisamment long pour être valide.",
|
|
||||||
);
|
|
||||||
cy.contains("button", "Envoyer").click();
|
|
||||||
|
|
||||||
cy.wait("@submitContact");
|
|
||||||
cy.get(".field-error").should("be.visible");
|
|
||||||
cy.contains("raw internal boom").should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
Feature: Contact form
|
|
||||||
As a signed-in user
|
|
||||||
I want to report a bug or send a suggestion from within the app
|
|
||||||
So that the team hears about it with the technical context attached
|
|
||||||
|
|
||||||
Background:
|
|
||||||
Given I am signed in as "Alice" "Martin"
|
|
||||||
|
|
||||||
Scenario: Sending a bug report shows a confirmation and forwards the page context
|
|
||||||
Given submitting a contact message will succeed
|
|
||||||
When I visit "/parametres/contact"
|
|
||||||
And I select "Bug" from the "contactType" field
|
|
||||||
And I fill in the "contactMessage" field with "Le bouton Envoyer ne réagit pas sur la page planning."
|
|
||||||
And I click the button "Envoyer"
|
|
||||||
Then the contact request should have been sent with type "bug"
|
|
||||||
And the contact request body should carry the current page URL and app version
|
|
||||||
And I should see "Merci ! Votre message a bien été envoyé."
|
|
||||||
|
|
||||||
Scenario: A too-short message is rejected on the client, without a request
|
|
||||||
Given submitting a contact message will succeed
|
|
||||||
When I visit "/parametres/contact"
|
|
||||||
And I fill in the "contactMessage" field with "court"
|
|
||||||
And I click the button "Envoyer"
|
|
||||||
Then I should see "Le message doit faire au moins 10 caractères"
|
|
||||||
And no contact request should have been sent
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
|
|
||||||
|
|
||||||
// Contact-form-specific steps (issue #80). Signing in and the generic
|
|
||||||
// select/type/click/see steps come from support/step_definitions/.
|
|
||||||
// Mocks the API via cy.intercept — this job runs no live backend (see
|
|
||||||
// .github/workflows/ci.yml); apps/api's Mocha suite covers `POST /contact`
|
|
||||||
// against a real database.
|
|
||||||
|
|
||||||
Given("submitting a contact message will succeed", () => {
|
|
||||||
// 204, matching the real route — the client's `_request` treats only 204
|
|
||||||
// as a bodyless success (a 201 with no body makes its `.json()` throw).
|
|
||||||
cy.intercept("POST", "**/contact", { statusCode: 204 }).as("submitContact");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the contact request should have been sent with type {string}", (type: string) => {
|
|
||||||
cy.wait("@submitContact").its("request.body.type").should("equal", type);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the contact request body should carry the current page URL and app version", () => {
|
|
||||||
cy.get("@submitContact.all").should("have.length", 1);
|
|
||||||
cy.get("@submitContact")
|
|
||||||
.its("request.body")
|
|
||||||
.should((body: Record<string, unknown>) => {
|
|
||||||
expect(body.pageUrl).to.equal("/parametres/contact");
|
|
||||||
expect(body.appVersion).to.be.a("string").and.not.equal("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("no contact request should have been sent", () => {
|
|
||||||
cy.get("@submitContact.all").should("have.length", 0);
|
|
||||||
});
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
// `GET /cooking-session` behavior (including the optimizer) against a real
|
// `GET /cooking-session` behavior (including the optimizer) against a real
|
||||||
// database.
|
// database.
|
||||||
|
|
||||||
import { PHONE_VIEWPORT } from "../support/viewports";
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
const authenticatedProfile = {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: "Alice",
|
firstName: "Alice",
|
||||||
|
|
@ -423,72 +421,3 @@ describe("Cooking session page", () => {
|
||||||
cy.contains("Impossible de charger").should("be.visible");
|
cy.contains("Impossible de charger").should("be.visible");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ticket #89 — this page's first dedicated responsive pass (it previously
|
|
||||||
// had no `@media` rule at all, see cooking-session-page.scss's own header
|
|
||||||
// comment). No layout is restructured on phone (the flex-column shell and
|
|
||||||
// the two horizontally-scrolling rows already degrade safely) — this covers
|
|
||||||
// the two things that actually needed fixing: comfortable touch targets on
|
|
||||||
// the screen's own controls, and a real flex overflow bug in the Recettes
|
|
||||||
// tab (a long recipe name had no upper bound and could crowd its portions
|
|
||||||
// badge/chevron out of the row).
|
|
||||||
describe("Cooking session page (phone)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.setViewport("phone");
|
|
||||||
cy.clock(TODAY, ["Date"]);
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives the task cards and stepper nodes a taller, more tappable padding than on desktop", () => {
|
|
||||||
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
|
|
||||||
cy.visit("/cuisiner?date=2026-08-17");
|
|
||||||
|
|
||||||
cy.get(".cooking-task")
|
|
||||||
.first()
|
|
||||||
.should(($el) => {
|
|
||||||
// --space-md (16px) on phone vs. the shared checkbox-label rule's
|
|
||||||
// own --space-xs (4px) app-wide default — see cooking-session-page
|
|
||||||
// .scss's phone block.
|
|
||||||
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
|
|
||||||
});
|
|
||||||
cy.get(".cooking-stepper__node")
|
|
||||||
.first()
|
|
||||||
.should(($el) => {
|
|
||||||
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The document itself never scrolls sideways, same global rule every
|
|
||||||
// other page is held to.
|
|
||||||
cy.document()
|
|
||||||
.its("documentElement.scrollWidth")
|
|
||||||
.should("be.lte", PHONE_VIEWPORT.width + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("truncates a long recipe name in the Recettes tab instead of pushing its portions badge out of view", () => {
|
|
||||||
const longName =
|
|
||||||
"Un très long nom de recette qui ne devrait surtout pas pousser le badge de portions hors de l'écran";
|
|
||||||
cy.intercept("GET", /\/cooking-session\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
recipes: [{ recipeId: 1, name: longName, portions: 4 }],
|
|
||||||
phases: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cy.visit("/cuisiner?date=2026-08-17");
|
|
||||||
cy.contains(".cooking-session-page__tab", "Recettes").click();
|
|
||||||
|
|
||||||
cy.get(".cooking-recipes__name").should(($el) => {
|
|
||||||
const s = getComputedStyle($el[0]);
|
|
||||||
expect(s.textOverflow).to.equal("ellipsis");
|
|
||||||
expect(s.whiteSpace).to.equal("nowrap");
|
|
||||||
// The name's own box is clipped narrower than its full text — proof
|
|
||||||
// the truncation is actually engaged, not just declared.
|
|
||||||
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
|
|
||||||
});
|
|
||||||
// ...and the badge survives, still on screen, not clipped away by the
|
|
||||||
// card's own `overflow: hidden`.
|
|
||||||
cy.contains(".cooking-recipes__portions", "4 portions").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// Pure layout spec, no Cucumber — a single static page with no user
|
|
||||||
// journey of its own (see layout.cy.ts's identical rationale for this
|
|
||||||
// 3-way test split). No `cy.intercept("**/auth/me", ...)` needed: this
|
|
||||||
// route renders outside RequireAuth entirely (see App.tsx's own doc
|
|
||||||
// comment on why), so it never calls that endpoint.
|
|
||||||
|
|
||||||
describe("DownloadPage — the mobile-app-redirect fallback", () => {
|
|
||||||
it("renders the APK download link, a retry button, and a way back home", () => {
|
|
||||||
cy.visit("/telecharger");
|
|
||||||
|
|
||||||
cy.contains("h1", "Installer l'application Android").should("be.visible");
|
|
||||||
|
|
||||||
// API_BASE_URL is empty locally (relative-URL, same-origin default —
|
|
||||||
// see client.ts), so the real target collapses to this relative path.
|
|
||||||
cy.contains("a", "Télécharger l'APK").should("have.attr", "href", "/mobile/download");
|
|
||||||
|
|
||||||
cy.contains("button", "Réessayer d'ouvrir l'application").should("be.visible");
|
|
||||||
|
|
||||||
cy.contains("a", "Retour à l'accueil").should("have.attr", "href", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is reachable without being logged in — no bounce to /login", () => {
|
|
||||||
cy.visit("/telecharger");
|
|
||||||
cy.url().should("include", "/telecharger");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Issue #107 — the "toujours rester sur le site web" preference moved
|
|
||||||
// here from "Paramètres d'application" (packaged-app only now). Written to
|
|
||||||
// `localStorage` on the public web origin, which is where
|
|
||||||
// `attemptOpenNativeApp` reads it.
|
|
||||||
it("defaults 'stay on web' to unchecked, and persists a toggle across a reload", () => {
|
|
||||||
cy.visit("/telecharger");
|
|
||||||
|
|
||||||
cy.contains("label", "Toujours rester sur le site web")
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.should("not.be.checked");
|
|
||||||
|
|
||||||
cy.contains("label", "Toujours rester sur le site web").click();
|
|
||||||
cy.contains("label", "Toujours rester sur le site web")
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.should("be.checked");
|
|
||||||
cy.window().then((win) => {
|
|
||||||
expect(win.localStorage.getItem("batchcooking:stayOnWeb")).to.equal("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.reload();
|
|
||||||
cy.contains("label", "Toujours rester sur le site web")
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.should("be.checked");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -22,17 +22,11 @@ Feature: Public landing page at the root
|
||||||
When I click the link "Créer un compte"
|
When I click the link "Créer un compte"
|
||||||
Then the URL should include "/signup"
|
Then the URL should include "/signup"
|
||||||
|
|
||||||
Scenario: The landing page offers the Android app download, on desktop
|
Scenario: The landing page offers the Android app download
|
||||||
Given I am not signed in
|
Given I am not signed in
|
||||||
When I visit "/"
|
When I visit "/"
|
||||||
Then I should see the Android download link
|
Then I should see the Android download link
|
||||||
|
|
||||||
Scenario: A mobile visitor is offered to open the app first, not just a download link
|
|
||||||
Given I am not signed in
|
|
||||||
When I visit "/" as an Android phone browser
|
|
||||||
Then I should see the button to open the app, with sign-up demoted to secondary
|
|
||||||
And the Android download link should not be shown
|
|
||||||
|
|
||||||
Scenario: Another guarded route still redirects a logged-out visitor to sign in
|
Scenario: Another guarded route still redirects a logged-out visitor to sign in
|
||||||
Given I am not signed in
|
Given I am not signed in
|
||||||
When I visit "/recettes"
|
When I visit "/recettes"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
import { Then } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
// Landing-page-specific steps. Everything else this feature needs
|
// Landing-page-specific steps. Everything else this feature needs
|
||||||
// (signing in / not, visiting a path, URL and heading assertions) comes
|
// (signing in / not, visiting a path, URL and heading assertions) comes
|
||||||
|
|
@ -15,50 +15,15 @@ Then("I should see the landing call to action", () => {
|
||||||
.and("have.attr", "href", "/login");
|
.and("have.attr", "href", "/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue #85: desktop only now (see LandingPage.tsx) — routed through
|
// The Android build is distributed as a Forgejo release asset with a stable
|
||||||
// `/telecharger` (DownloadPage.tsx, issue #84's fallback page) rather than
|
// filename (issue #64 builds/attaches it). We only assert the link is there
|
||||||
// linking the Android build's actual `/mobile/download` redirect directly,
|
// and points at the right URL — the file itself is external to the suite.
|
||||||
// so "get the app" has exactly one canonical destination app-wide.
|
|
||||||
Then("I should see the Android download link", () => {
|
Then("I should see the Android download link", () => {
|
||||||
cy.get(".landing__hero .landing__mobile-link")
|
cy.get(".landing__hero .landing__mobile-link")
|
||||||
.should("be.visible")
|
.should("be.visible")
|
||||||
.and("have.attr", "href", "/telecharger");
|
.and(
|
||||||
});
|
"have.attr",
|
||||||
|
"href",
|
||||||
// Spoofs a real Android phone Chrome UA via `onBeforeLoad` — the one place
|
"https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest/download/batchcooking.apk",
|
||||||
// `isAndroidPhoneBrowser()` (`lib/mobile-app-redirect.ts`) actually reads
|
);
|
||||||
// from, so this is the only way to reach the landing page's mobile-only
|
|
||||||
// branch from a real page visit (Cypress's own Electron browser always
|
|
||||||
// reports a desktop UA otherwise). Same literal UA string already used by
|
|
||||||
// `mobile-app-redirect.cy.tsx`'s own `isAndroidPhoneBrowser` unit test.
|
|
||||||
When("I visit {string} as an Android phone browser", (path: string) => {
|
|
||||||
cy.visit(path, {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
Object.defineProperty(win.navigator, "userAgent", {
|
|
||||||
value:
|
|
||||||
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36",
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("I should see the button to open the app, with sign-up demoted to secondary", () => {
|
|
||||||
// Deliberately never clicked here: its `onClick` calls
|
|
||||||
// `attemptOpenNativeApp()` with the real (production) `openAppUrl`, a
|
|
||||||
// genuine `window.location.href` to an unrecognized custom scheme — that
|
|
||||||
// hangs the Cypress/Electron runner indefinitely rather than safely
|
|
||||||
// no-op'ing (see dev-environment-gotchas.md). The function itself is
|
|
||||||
// already fully unit-tested with an injected stub in
|
|
||||||
// mobile-app-redirect.cy.tsx; this only covers LandingPage's own
|
|
||||||
// conditional rendering.
|
|
||||||
cy.get(".landing__hero .landing__cta--primary")
|
|
||||||
.should("be.visible")
|
|
||||||
.and("contain.text", "Ouvrir dans l'application");
|
|
||||||
cy.contains(".landing__hero .landing__cta--secondary", "Créer un compte").should("be.visible");
|
|
||||||
cy.contains(".landing__hero .landing__cta--secondary", "Se connecter").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the Android download link should not be shown", () => {
|
|
||||||
cy.get(".landing__hero .landing__mobile-link").should("not.exist");
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@
|
||||||
// width (some stuck to the left edge with a lopsided gap, others correctly
|
// width (some stuck to the left edge with a lopsided gap, others correctly
|
||||||
// full-bleed).
|
// full-bleed).
|
||||||
|
|
||||||
import { PHONE_VIEWPORT } from "../support/viewports";
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
const authenticatedProfile = {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: "Alice",
|
firstName: "Alice",
|
||||||
|
|
@ -174,86 +172,50 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 640px", () => {
|
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {
|
||||||
// Regression coverage for the mobile/tablet UI chantier's foundation
|
|
||||||
// ticket: below this point the desktop `<aside>` sidebar isn't reflowed
|
|
||||||
// by CSS anymore, it's replaced outright by a fixed bottom nav (a
|
|
||||||
// genuinely different component tree — see AppLayout.tsx's own doc
|
|
||||||
// comment on why). This block used to test the old CSS-squash treatment
|
|
||||||
// (sidebar → horizontal top bar); that treatment no longer exists.
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
interceptAuth();
|
interceptAuth();
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the bottom nav instead of the sidebar, icon-only, with a working Plus tab", () => {
|
it("switches to a full-width horizontal bar, hides the collapse toggle and version tag, and keeps every nav link legible", () => {
|
||||||
cy.setViewport("phone");
|
cy.viewport(375, 812);
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
||||||
// The desktop sidebar isn't just hidden — it's not in the DOM at all.
|
cy.get(".app-sidebar").should(($sidebar) => {
|
||||||
cy.get(".app-sidebar").should("not.exist");
|
const rect = $sidebar[0].getBoundingClientRect();
|
||||||
|
expect(rect.width).to.be.closeTo(375, 1);
|
||||||
cy.get(".app-bottom-nav").should(($nav) => {
|
// A short horizontal bar, not the tall vertical rail — well under the
|
||||||
const rect = $nav[0].getBoundingClientRect();
|
// desktop rail's own content-driven height.
|
||||||
expect(rect.width).to.be.closeTo(PHONE_VIEWPORT.width, 1);
|
|
||||||
// A short bar flush against the bottom edge, not a tall rail.
|
|
||||||
expect(rect.height).to.be.lessThan(120);
|
expect(rect.height).to.be.lessThan(120);
|
||||||
expect(rect.bottom).to.be.closeTo(PHONE_VIEWPORT.height, 1);
|
|
||||||
expect(getComputedStyle($nav[0]).position).to.equal("fixed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue #105 — icon-only bar: each tab's `span.label` stays in the DOM
|
// Nothing to collapse into on a bar with no rail to shrink.
|
||||||
// (accessible name for the link) but is visually hidden, only the icon
|
cy.get(".app-sidebar__collapse-toggle").should("not.be.visible");
|
||||||
// shows. Same treatment as the desktop collapsed rail (sidebar.cy.ts).
|
cy.get(".app-sidebar__version").should("not.be.visible");
|
||||||
cy.contains(".app-bottom-nav a", "Planning")
|
|
||||||
.should("have.attr", "href", "/")
|
// The main nav must stay fully legible and tappable — icon *and* label
|
||||||
.within(() => {
|
// — falling back to horizontal scroll instead of ever being crushed
|
||||||
cy.get("span.label").should("exist").and("not.be.visible");
|
// down to unlabeled slivers (see AppLayout.scss's own comment on this
|
||||||
cy.get("svg").should("be.visible");
|
// exact failure mode).
|
||||||
|
cy.get(".app-sidebar__nav").should(($nav) => {
|
||||||
|
expect(getComputedStyle($nav[0]).overflowX).to.equal("auto");
|
||||||
});
|
});
|
||||||
cy.contains(".app-bottom-nav a", "Recettes").should("have.attr", "href", "/recettes");
|
cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible");
|
||||||
cy.contains(".app-bottom-nav a", "Liste de courses").should(
|
cy.contains(".app-sidebar__nav a", "Planning").should(($link) => {
|
||||||
"have.attr",
|
expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40);
|
||||||
"href",
|
|
||||||
"/liste-de-courses",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The "Plus" tab opens a bottom sheet with Paramètres + Compte, closed
|
|
||||||
// by default.
|
|
||||||
cy.get(".bottom-sheet").should("not.exist");
|
|
||||||
cy.contains(".app-bottom-nav button", "Plus").click();
|
|
||||||
|
|
||||||
cy.get(".bottom-sheet").should("be.visible");
|
|
||||||
cy.contains(".bottom-sheet", "Bonjour Alice").should("be.visible");
|
|
||||||
cy.contains(".bottom-sheet a", "Compte").should("have.attr", "href", "/parametres/compte");
|
|
||||||
cy.contains(".bottom-sheet a", "Préférences alimentaires").should(
|
|
||||||
"have.attr",
|
|
||||||
"href",
|
|
||||||
"/parametres/preferences",
|
|
||||||
);
|
|
||||||
cy.contains(".bottom-sheet button", "Se déconnecter").should("be.visible");
|
|
||||||
|
|
||||||
// The close button calls the native `<dialog>`'s own `.close()`, whose
|
|
||||||
// `close` event `useNativeDialogBehavior` forwards back to
|
|
||||||
// `MobileBottomNav`'s own state — same wiring Escape/tap-outside use,
|
|
||||||
// exercised here since it doesn't depend on this Cypress/Electron
|
|
||||||
// environment faithfully replicating the browser's own native
|
|
||||||
// Escape-closes-a-modal-dialog behavior (a browser built-in this app
|
|
||||||
// doesn't implement itself — see Dialog.tsx's own doc comment).
|
|
||||||
cy.get(".bottom-sheet__close").click();
|
|
||||||
cy.get(".bottom-sheet").should("not.exist");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the desktop sidebar (not the bottom nav) at a tablet viewport", () => {
|
// Contrast: the settings/account toggles' labels *do* collapse to
|
||||||
cy.setViewport("tablet");
|
// icon-only here — there's no room for both a full nav row and full
|
||||||
cy.visit("/");
|
// text labels on every piece of chrome at once.
|
||||||
|
cy.contains("button", "Paramètres").find("span.label").should("not.be.visible");
|
||||||
cy.get(".app-sidebar").should("exist");
|
|
||||||
cy.get(".app-bottom-nav").should("not.exist");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Responsive breakpoint — page content under 640px (issue #39)", () => {
|
describe("Responsive breakpoint — page content under 640px (issue #39)", () => {
|
||||||
|
const PHONE: [number, number] = [375, 812];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
interceptAuth();
|
interceptAuth();
|
||||||
});
|
});
|
||||||
|
|
@ -270,7 +232,7 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
|
||||||
expect(s.paddingLeft).to.equal("32px");
|
expect(s.paddingLeft).to.equal("32px");
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.setViewport("phone");
|
cy.viewport(...PHONE);
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
cy.get(".app-content").should(($el) => {
|
cy.get(".app-content").should(($el) => {
|
||||||
const s = getComputedStyle($el[0]);
|
const s = getComputedStyle($el[0]);
|
||||||
|
|
@ -280,20 +242,7 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Until issue #88, a phone viewport just let the 7-column grid scroll
|
it("keeps the 7-day planning grid on its own horizontal scroll rather than overflowing the page", () => {
|
||||||
// horizontally inside `.planning-grid-wrapper` (a shrunk desktop table,
|
|
||||||
// not a real mobile layout). #88 replaces it outright with
|
|
||||||
// `PlanningDayView` below the phone breakpoint — this test now covers
|
|
||||||
// *that* switch; the day view's own day-by-day content is
|
|
||||||
// PlanningPage.tsx's own spec (planning-page.cy.ts).
|
|
||||||
it("shows the one-day mobile view instead of the horizontally-scrolling grid, on a phone", () => {
|
|
||||||
// `PlanningDayView` opens on *today's* weekday within the shown week
|
|
||||||
// (`defaultDayIndexForWeek`, derived from `Date`). Freeze "today" to a
|
|
||||||
// Monday so it opens on "lundi" — where the mocked recipe below is —
|
|
||||||
// instead of drifting with the real date (this test used to pass only
|
|
||||||
// when CI happened to run on a Monday, UTC). `["Date"]` only, so
|
|
||||||
// timers/debounces still run.
|
|
||||||
cy.clock(new Date("2026-08-17T09:00:00.000Z"), ["Date"]);
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
cy.intercept("GET", /\/planning\?/, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -315,25 +264,25 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.setViewport("phone");
|
cy.viewport(...PHONE);
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
||||||
// The desktop grid is gone entirely, not just visually — the day view
|
// The grid wrapper is the scroll container, and its content is genuinely
|
||||||
// takes over the whole page.
|
// wider than the phone viewport (7 columns never crushed).
|
||||||
cy.get(".planning-grid-wrapper").should("not.exist");
|
cy.get(".planning-grid-wrapper").should(($el) => {
|
||||||
cy.get(".planning-day-view").should("be.visible");
|
expect(getComputedStyle($el[0]).overflowX).to.be.oneOf(["auto", "scroll"]);
|
||||||
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
|
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
|
||||||
|
});
|
||||||
|
|
||||||
// The document itself never scrolls sideways (global rule against a
|
// ...and that overflow stays *inside* the wrapper — the document itself
|
||||||
// horizontally-scrolling body) — still true for the day view's own
|
// never scrolls sideways (global rule against a horizontally-scrolling
|
||||||
// stacked-card layout, same as every other page.
|
// body).
|
||||||
cy.document()
|
cy.document()
|
||||||
.its("documentElement.scrollWidth")
|
.its("documentElement.scrollWidth")
|
||||||
.should("be.lte", PHONE_VIEWPORT.width + 1);
|
.should("be.lte", 375 + 1);
|
||||||
|
|
||||||
// The per-recipe remove "✕" is hover-gated on desktop; on touch it must
|
// The per-recipe remove "✕" is hover-gated on desktop; on touch it must
|
||||||
// be permanently visible or a planned recipe can't be removed at all —
|
// be permanently visible or a planned recipe can't be removed at all.
|
||||||
// same shared `.recipe-chip__remove` class either layout renders.
|
|
||||||
cy.get(".recipe-chip__remove")
|
cy.get(".recipe-chip__remove")
|
||||||
.first()
|
.first()
|
||||||
.should(($btn) => {
|
.should(($btn) => {
|
||||||
|
|
|
||||||
|
|
@ -105,23 +105,4 @@ describe("Leftovers basket", () => {
|
||||||
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
|
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
|
||||||
cy.get(".leftovers-basket__count").should("not.exist");
|
cy.get(".leftovers-basket__count").should("not.exist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens inside a bottom sheet on a phone instead of the anchored popover", () => {
|
|
||||||
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
|
|
||||||
// that command doesn't exist on this ticket's own base yet; matches its
|
|
||||||
// PHONE_VIEWPORT value on purpose, worth switching to the real import
|
|
||||||
// once both have landed on `main`.
|
|
||||||
cy.viewport(390, 844);
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
cy.get(".leftovers-basket__toggle").click();
|
|
||||||
cy.get(".leftovers-basket__popover").should("not.exist");
|
|
||||||
cy.get(".bottom-sheet").should("be.visible");
|
|
||||||
cy.contains(".bottom-sheet", "Restes disponibles").should("be.visible");
|
|
||||||
cy.contains(".bottom-sheet .leftovers-basket__line", "Lasagnes").should("be.visible");
|
|
||||||
|
|
||||||
cy.get(".bottom-sheet__close").click();
|
|
||||||
cy.get(".bottom-sheet").should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,41 @@
|
||||||
Feature: Onboarding wizard
|
Feature: Onboarding wizard
|
||||||
As a newly signed-up user
|
As a newly signed-up user
|
||||||
I want to set my preferences (diet + allergens), then my household
|
I want to set my diet, household, and allergens
|
||||||
So that my profile is ready before I start planning meals
|
So that my profile is ready before I start planning meals
|
||||||
|
|
||||||
# Order since #129: compte -> régime -> allergènes -> foyer -> sources,
|
|
||||||
# and the sources step only when the foyer step *created* a household
|
|
||||||
# (joining one or skipping ends the wizard at the home page).
|
|
||||||
|
|
||||||
Background:
|
Background:
|
||||||
Given the planning request returns nothing
|
Given the planning request returns nothing
|
||||||
|
|
||||||
Scenario: Walks through every step, creating a household (which surfaces the sources step), and lands on the home page
|
Scenario: Walks through the wizard, creating a household on the way (which surfaces the sources step), and lands on the home page
|
||||||
Given the diets reference list has options
|
Given the diets reference list has options
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
|
And creating a household will succeed
|
||||||
|
And the sources reference list is empty
|
||||||
And the allergies reference list has options
|
And the allergies reference list has options
|
||||||
And updating allergies will succeed
|
And updating allergies will succeed
|
||||||
And creating a household will succeed
|
|
||||||
And the sources reference list has options
|
|
||||||
And the household's enabled sources are empty
|
|
||||||
And saving the source selection will succeed
|
|
||||||
And I have signed up
|
And I have signed up
|
||||||
Then the URL should include "/onboarding/regime"
|
Then the URL should include "/onboarding/regime"
|
||||||
And I should see "Étape 1 sur 3"
|
And I should see "Étape 1 sur 3"
|
||||||
When I select "Végétarien" from the "diet" field
|
When I select "Végétarien" from the "diet" field
|
||||||
And I click the button "Continuer"
|
And I click the button "Continuer"
|
||||||
Then the diet update request should have been made with diet id 2
|
Then the diet update request should have been made with diet id 2
|
||||||
And the URL should include "/onboarding/allergenes"
|
|
||||||
And I should see "Étape 2 sur 3"
|
|
||||||
And I should see the section "Allergies"
|
|
||||||
And I should see the section "Intolérances"
|
|
||||||
When I check the checkbox "Arachides"
|
|
||||||
And I click the button "Continuer"
|
|
||||||
Then the allergies update request should have been made with allergy id 1
|
|
||||||
And the URL should include "/onboarding/foyer"
|
And the URL should include "/onboarding/foyer"
|
||||||
And I should see "Étape 3 sur 3"
|
And I should see "Étape 2 sur 3"
|
||||||
When I fill in the "houseName" field with "Chez Alice"
|
When I fill in the "houseName" field with "Chez Alice"
|
||||||
And I click the button "Créer"
|
And I click the button "Créer"
|
||||||
Then the household creation request should have been made with name "Chez Alice"
|
Then the household creation request should have been made with name "Chez Alice"
|
||||||
And the URL should include "/onboarding/sources"
|
And the URL should include "/onboarding/allergenes"
|
||||||
And I should see "Étape 4 sur 4"
|
And I should see "Étape 4 sur 4"
|
||||||
And I should see the section "Sources disponibles"
|
And I should see the section "Allergies"
|
||||||
And I should see "TheMealDB"
|
And I should see the section "Intolérances"
|
||||||
When I check the checkbox "TheMealDB"
|
When I check the checkbox "Arachides"
|
||||||
And I click the button "Terminer"
|
And I click the button "Terminer"
|
||||||
Then the source selection update request should have been made with source id 1
|
Then the allergies update request should have been made with allergy id 1
|
||||||
And the URL should be the home page
|
And the URL should be the home page
|
||||||
And I should see the heading "Planning de la semaine"
|
And I should see the heading "Planning de la semaine"
|
||||||
|
|
||||||
Scenario: Lets the preferences steps be skipped without changing anything, then skips the household step too
|
Scenario: Lets the regime and allergens steps be skipped without changing anything
|
||||||
Given the diets reference list is empty
|
Given the diets reference list is empty
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
|
|
@ -58,32 +45,39 @@ Feature: Onboarding wizard
|
||||||
Then the URL should include "/onboarding/regime"
|
Then the URL should include "/onboarding/regime"
|
||||||
When I click the button "Continuer"
|
When I click the button "Continuer"
|
||||||
Then the diet update request should have been made with no diet id
|
Then the diet update request should have been made with no diet id
|
||||||
And the URL should include "/onboarding/allergenes"
|
|
||||||
When I click the button "Continuer"
|
|
||||||
Then the allergies update request should have been made with no allergy ids
|
|
||||||
And the URL should include "/onboarding/foyer"
|
And the URL should include "/onboarding/foyer"
|
||||||
When I click the button "Passer cette étape"
|
When I click the button "Passer cette étape"
|
||||||
Then the URL should be the home page
|
Then the URL should include "/onboarding/allergenes"
|
||||||
|
When I click the button "Terminer"
|
||||||
|
Then the allergies update request should have been made with no allergy ids
|
||||||
|
And the URL should be the home page
|
||||||
|
|
||||||
Scenario: Creating a household with no sources implemented ends the wizard through the silently-skipped sources step
|
Scenario: Shows and saves the sources step instead of skipping it, when sources are available
|
||||||
Given the diets reference list is empty
|
Given the diets reference list is empty
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
And creating a household will succeed
|
And creating a household will succeed
|
||||||
And the sources reference list is empty
|
And the sources reference list has options
|
||||||
|
And the household's enabled sources are empty
|
||||||
|
And saving the source selection will succeed
|
||||||
And the allergies reference list is empty
|
And the allergies reference list is empty
|
||||||
And updating allergies will succeed
|
And updating allergies will succeed
|
||||||
And I have signed up
|
And I have signed up
|
||||||
When I click the button "Continuer"
|
When I click the button "Continuer"
|
||||||
Then the URL should include "/onboarding/allergenes"
|
|
||||||
When I click the button "Continuer"
|
|
||||||
Then the URL should include "/onboarding/foyer"
|
Then the URL should include "/onboarding/foyer"
|
||||||
When I fill in the "houseName" field with "Chez Alice"
|
When I fill in the "houseName" field with "Chez Alice"
|
||||||
And I click the button "Créer"
|
And I click the button "Créer"
|
||||||
Then the household creation request should have been made with name "Chez Alice"
|
Then the household creation request should have been made with name "Chez Alice"
|
||||||
And the URL should be the home page
|
And the URL should include "/onboarding/sources"
|
||||||
|
And I should see "Étape 3 sur 4"
|
||||||
|
And I should see the section "Sources disponibles"
|
||||||
|
And I should see "TheMealDB"
|
||||||
|
When I check the checkbox "TheMealDB"
|
||||||
|
And I click the button "Continuer"
|
||||||
|
Then the source selection update request should have been made with source id 1
|
||||||
|
And the URL should include "/onboarding/allergenes"
|
||||||
|
|
||||||
Scenario: Joining an existing household skips the sources step entirely and ends the wizard
|
Scenario: Lets the household step be completed by joining an existing household instead of creating one
|
||||||
Given the diets reference list is empty
|
Given the diets reference list is empty
|
||||||
And selecting the diet will succeed
|
And selecting the diet will succeed
|
||||||
And the household request returns no household
|
And the household request returns no household
|
||||||
|
|
@ -93,10 +87,8 @@ Feature: Onboarding wizard
|
||||||
And updating allergies will succeed
|
And updating allergies will succeed
|
||||||
And I have signed up
|
And I have signed up
|
||||||
When I click the button "Continuer"
|
When I click the button "Continuer"
|
||||||
Then the URL should include "/onboarding/allergenes"
|
|
||||||
When I click the button "Continuer"
|
|
||||||
Then the URL should include "/onboarding/foyer"
|
Then the URL should include "/onboarding/foyer"
|
||||||
When I fill in the "inviteCode" field with "abcd2345"
|
When I fill in the "inviteCode" field with "abcd2345"
|
||||||
And I click the button "Rejoindre"
|
And I click the button "Rejoindre"
|
||||||
Then the household join request should have been made with invite code "ABCD2345"
|
Then the household join request should have been made with invite code "ABCD2345"
|
||||||
And the URL should be the home page
|
And the URL should include "/onboarding/allergenes"
|
||||||
|
|
|
||||||
|
|
@ -182,49 +182,6 @@ describe("Planning grid", () => {
|
||||||
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
|
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue #110 — a slot with many recipes used to stretch its `<td>`, and
|
|
||||||
// with it the whole row and the table, past `.planning-grid-wrapper`
|
|
||||||
// into a vertical scroll (the other cells of that row just gaining dead
|
|
||||||
// space). The chip list now scrolls inside its own cell instead.
|
|
||||||
it("keeps a heavy slot's recipes scrolling inside the cell, not stretching the grid", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
items: Array.from({ length: 14 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
portions: 2,
|
|
||||||
recipe: { id: i + 1, name: `Recette ${i + 1}` },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.get(".recipe-chip").should("have.length", 14);
|
|
||||||
|
|
||||||
// The busy cell's own recipe list is now the scroller: it's capped and
|
|
||||||
// scrolls its own overflow instead of stretching the `<td>` (and with
|
|
||||||
// it the whole row, then the table past `.planning-grid-wrapper` into a
|
|
||||||
// tall vertical scroll).
|
|
||||||
cy.get(".meal-cell__recipes").should(($lists) => {
|
|
||||||
const list = [...$lists].find(
|
|
||||||
(el) => el.querySelectorAll(".recipe-chip").length === 14,
|
|
||||||
) as HTMLElement;
|
|
||||||
expect(list, "the 14-recipe cell's list").to.exist;
|
|
||||||
expect(getComputedStyle(list).overflowY).to.be.oneOf(["auto", "scroll"]);
|
|
||||||
expect(list.scrollHeight).to.be.greaterThan(list.clientHeight);
|
|
||||||
expect(list.clientHeight).to.be.lessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.get(".planning-grid-wrapper").should(($w) => {
|
|
||||||
expect($w[0].scrollHeight).to.be.lessThan($w[0].clientHeight * 1.25);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
|
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
||||||
|
|
||||||
|
|
@ -240,179 +197,4 @@ describe("Planning grid", () => {
|
||||||
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
||||||
cy.get(".calendar-popover").should("not.exist");
|
cy.get(".calendar-popover").should("not.exist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the calendar inside a bottom sheet on a phone instead of the anchored popover", () => {
|
|
||||||
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
|
|
||||||
// that command doesn't exist on this ticket's own base yet; matches its
|
|
||||||
// PHONE_VIEWPORT value on purpose, worth switching to the real import
|
|
||||||
// once both have landed on `main`.
|
|
||||||
cy.viewport(390, 844);
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning");
|
|
||||||
|
|
||||||
cy.contains("button", "Semaine du").click();
|
|
||||||
cy.get(".calendar-popover").should("not.exist");
|
|
||||||
cy.get(".bottom-sheet").should("be.visible");
|
|
||||||
cy.contains(".bottom-sheet", "Choisir une semaine").should("be.visible");
|
|
||||||
|
|
||||||
cy.get(".bottom-sheet .calendar-grid__day").contains(/^25$/).click();
|
|
||||||
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
|
||||||
cy.get(".bottom-sheet").should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ticket #88 — the 7-column grid gives way entirely to `PlanningDayView` on
|
|
||||||
// a phone (not the same table, scrolled): a switcher plus one day's meals
|
|
||||||
// stacked as cards. `.recipe-chip`/`.add-recipe-btn` are the same elements
|
|
||||||
// either layout renders (see `mealCellBody`, shared by `MealCell` and
|
|
||||||
// `MealCellRow` in PlanningPage.tsx), so this only covers what's actually
|
|
||||||
// different here — day selection, its clamped edges, and that the day
|
|
||||||
// view's own "+" still opens the real recipe picker.
|
|
||||||
describe("Planning day view (phone)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.setViewport("phone");
|
|
||||||
freezeToday();
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to today, shows only that day's recipes, and clamps at Monday", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "diner",
|
|
||||||
portions: 4,
|
|
||||||
recipe: { id: 1, name: "Ratatouille" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
weekDay: "mardi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
portions: 2,
|
|
||||||
recipe: { id: 2, name: "Curry de lentilles" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}).as("getPlanning");
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning");
|
|
||||||
|
|
||||||
// The table is gone entirely on phone, not just visually hidden.
|
|
||||||
cy.get("table.planning-grid").should("not.exist");
|
|
||||||
cy.get(".planning-day-view").should("be.visible");
|
|
||||||
|
|
||||||
// Today (17 août, Lundi) is the default day — only its own recipe shows.
|
|
||||||
cy.contains(".planning-day-view__label", "Lundi").should("be.visible");
|
|
||||||
cy.contains(".planning-day-view__label", "17").should("be.visible");
|
|
||||||
cy.contains(".planning-day-view__label .today-badge", "Aujourd'hui").should("be.visible");
|
|
||||||
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
|
|
||||||
cy.contains(".recipe-chip", "Curry de lentilles").should("not.exist");
|
|
||||||
|
|
||||||
// Clamped, not wrapping: Monday is the week's first day.
|
|
||||||
cy.get(".planning-day-view__arrow").first().should("be.disabled");
|
|
||||||
|
|
||||||
cy.get(".planning-day-view__arrow").last().click();
|
|
||||||
cy.contains(".planning-day-view__label", "Mardi").should("be.visible");
|
|
||||||
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
|
|
||||||
cy.contains(".recipe-chip", "Ratatouille").should("not.exist");
|
|
||||||
cy.get(".planning-day-view__arrow").first().should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps at Sunday going forward, and its own '+' opens the recipe picker", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning");
|
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
cy.get(".planning-day-view__arrow").last().click();
|
|
||||||
}
|
|
||||||
cy.contains(".planning-day-view__label", "Dimanche").should("be.visible");
|
|
||||||
cy.get(".planning-day-view__arrow").last().should("be.disabled");
|
|
||||||
|
|
||||||
cy.get(".add-recipe-btn").first().click();
|
|
||||||
cy.get(".recipe-picker-dialog").should("be.visible");
|
|
||||||
cy.contains(".recipe-picker-dialog", "Choisir une recette").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Issue #111 — on a phone the recipe picker is a full-height flex column;
|
|
||||||
// a regression in the mobile full-screen-dialog CSS let its tall filter
|
|
||||||
// block (search + ingredient/diet pickers, rendered only for the four
|
|
||||||
// non-source tabs) consume the column and collapse the recipe table below
|
|
||||||
// it into a zero-height scrollport. The table was in the DOM the whole
|
|
||||||
// time, so Favoris / Perso / Foyer / Publique all just looked empty.
|
|
||||||
describe("Recipe picker dialog (phone)", () => {
|
|
||||||
// Full `RecipeSummaryView` shape — `RecipeTable` reads `allergens`/`diets`
|
|
||||||
// (`.length`/`.map`) on every row, so `{ id, name }` alone crashes it.
|
|
||||||
function summary(id: number, name: string) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description: null,
|
|
||||||
picture: null,
|
|
||||||
portions: 4,
|
|
||||||
authorId: 1,
|
|
||||||
visibility: "PERSONAL" as const,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
isFavorite: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const recipesByTab: Record<string, ReturnType<typeof summary>[]> = {
|
|
||||||
favoris: [summary(1, "Ratatouille")],
|
|
||||||
perso: [summary(2, "Omelette")],
|
|
||||||
foyer: [summary(3, "Gratin dauphinois")],
|
|
||||||
publique: [summary(4, "Soupe de potiron")],
|
|
||||||
};
|
|
||||||
const tabLabel: Record<string, string> = {
|
|
||||||
favoris: "Favoris",
|
|
||||||
perso: "Perso",
|
|
||||||
foyer: "Foyer",
|
|
||||||
publique: "Publique",
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.setViewport("phone");
|
|
||||||
freezeToday();
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
|
||||||
// The dialog's own reference-data / source fetches — all empty, so the
|
|
||||||
// filter block is only as tall as its always-present controls, which is
|
|
||||||
// already enough to trigger the collapse this covers.
|
|
||||||
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
|
|
||||||
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
|
||||||
const tab = new URL(req.url).searchParams.get("tab") ?? "favoris";
|
|
||||||
req.reply({ statusCode: 200, body: recipesByTab[tab] ?? [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the recipe table on every non-source tab, not a collapsed empty area", () => {
|
|
||||||
cy.visit("/");
|
|
||||||
cy.get(".add-recipe-btn").first().click();
|
|
||||||
cy.get(".recipe-picker-dialog").should("be.visible");
|
|
||||||
|
|
||||||
for (const tab of Object.keys(recipesByTab)) {
|
|
||||||
cy.contains(".recipe-tabs__tab", tabLabel[tab]).click();
|
|
||||||
// `be.visible` fails on a zero-height / clipped element — the exact
|
|
||||||
// shape of the regression.
|
|
||||||
cy.get(".recipe-picker-dialog .recipe-table").should("be.visible");
|
|
||||||
cy.get(".recipe-picker-dialog .recipe-table tbody tr").should("have.length.greaterThan", 0);
|
|
||||||
cy.contains(".recipe-picker-dialog .recipe-table__name", recipesByTab[tab][0].name).should(
|
|
||||||
"be.visible",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -68,47 +68,6 @@ Feature: Recipe form — associating ingredients
|
||||||
Then the ingredient "Carotte" should be visible in the picker
|
Then the ingredient "Carotte" should be visible in the picker
|
||||||
And there should be 0 ingredient rows
|
And there should be 0 ingredient rows
|
||||||
|
|
||||||
Scenario: Marks an ingredient as « selon le goût » and saves it with no quantity
|
|
||||||
Given creating the recipe will succeed and return id 44
|
|
||||||
When I visit "/recettes/nouvelle"
|
|
||||||
And I fill in the "recipe-name" field with "Tomates au sel"
|
|
||||||
And I select the ingredient "Tomate" from the picker
|
|
||||||
And I mark the ingredient "Tomate" as seasoning
|
|
||||||
Then the ingredient "Tomate" should have no quantity field
|
|
||||||
When I add a step
|
|
||||||
And I fill in the step description with "Saler."
|
|
||||||
Then the "Enregistrer" button should not be disabled
|
|
||||||
When I click the button "Enregistrer"
|
|
||||||
Then the recipe creation request should have included the seasoning-only ingredient 1
|
|
||||||
And the URL should include "/recettes/44"
|
|
||||||
|
|
||||||
Scenario: The recipe form has no regime picker — regimes are computed from ingredients (issue #131)
|
|
||||||
When I visit "/recettes/nouvelle"
|
|
||||||
Then I should not see "Régime(s) associé(s)"
|
|
||||||
|
|
||||||
Scenario: The save/cancel bar stays pinned to the foot of a long form
|
|
||||||
When I visit "/recettes/nouvelle"
|
|
||||||
And I add a step
|
|
||||||
And I add a step
|
|
||||||
And I scroll the recipe form to the top
|
|
||||||
Then the recipe form action bar is pinned to the bottom of the viewport
|
|
||||||
And I should see "Enregistrer"
|
|
||||||
And I should see "Annuler"
|
|
||||||
|
|
||||||
Scenario: Cancelling a new recipe goes back to the catalog
|
|
||||||
When I visit "/recettes/nouvelle"
|
|
||||||
And I fill in the "recipe-name" field with "Brouillon abandonné"
|
|
||||||
And I click the button "Annuler"
|
|
||||||
Then the URL should include "/recettes"
|
|
||||||
And the URL should not include "/nouvelle"
|
|
||||||
|
|
||||||
Scenario: Cancelling an edit goes back to that recipe
|
|
||||||
Given recipe 7 exists with an egg omelette
|
|
||||||
When I visit "/recettes/7/modifier"
|
|
||||||
And I click the button "Annuler"
|
|
||||||
Then the URL should include "/recettes/7"
|
|
||||||
And the URL should not include "/modifier"
|
|
||||||
|
|
||||||
Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another
|
Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another
|
||||||
Given recipe 7 exists with an egg omelette
|
Given recipe 7 exists with an egg omelette
|
||||||
And updating recipe 7 will succeed
|
And updating recipe 7 will succeed
|
||||||
|
|
|
||||||
|
|
@ -95,44 +95,6 @@ Then(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
When("I mark the ingredient {string} as seasoning", (name: string) => {
|
|
||||||
cy.contains(".ingredient-row", name)
|
|
||||||
.find(".ingredient-row__to-taste input[type=checkbox]")
|
|
||||||
.check({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the ingredient {string} should have no quantity field", (name: string) => {
|
|
||||||
cy.contains(".ingredient-row", name).find(".ingredient-row__quantity").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"the recipe creation request should have included the seasoning-only ingredient {int}",
|
|
||||||
(ingredientId: number) => {
|
|
||||||
cy.wait("@createRecipe")
|
|
||||||
.its("request.body.ingredients")
|
|
||||||
.should("deep.equal", [{ ingredientId }]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
When("I scroll the recipe form to the top", () => {
|
|
||||||
// `.app-content` is the one scroll container (AppLayout.scss) — the sticky
|
|
||||||
// action bar pins against it, so it's what has to be scrolled to prove it.
|
|
||||||
cy.get(".app-content").scrollTo("top");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the recipe form action bar is pinned to the bottom of the viewport", () => {
|
|
||||||
cy.get(".recipe-form__actions--sticky").then(($bar) => {
|
|
||||||
const rect = $bar[0].getBoundingClientRect();
|
|
||||||
const viewportHeight = Cypress.config("viewportHeight");
|
|
||||||
// Scrolled to the top of a form taller than the viewport, a pinned bar
|
|
||||||
// sits at the container's bottom padding edge (`.app-content` keeps its
|
|
||||||
// own `--space-xl` padding) rather than far below the fold — allow a
|
|
||||||
// generous band for that padding + the bar's own.
|
|
||||||
expect(rect.bottom).to.be.greaterThan(viewportHeight - 120);
|
|
||||||
expect(rect.bottom).to.be.at.most(viewportHeight + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Given("recipe 7 exists with an egg omelette", () => {
|
Given("recipe 7 exists with an egg omelette", () => {
|
||||||
const existingRecipe = {
|
const existingRecipe = {
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|
|
||||||
|
|
@ -80,19 +80,6 @@ Feature: Browsing external recipe sources
|
||||||
When I click the button "Réessayer"
|
When I click the button "Réessayer"
|
||||||
Then I should see the source item "Beef Wellington"
|
Then I should see the source item "Beef Wellington"
|
||||||
|
|
||||||
Scenario: Keeps the search term when switching between source tabs
|
|
||||||
Given the recipe catalog contains nothing
|
|
||||||
And the sources reference list has options
|
|
||||||
And the household has enabled TheMealDB and Marmiton
|
|
||||||
And browsing any enabled source echoes back its query
|
|
||||||
When I visit "/recettes"
|
|
||||||
And I click the button "TheMealDB"
|
|
||||||
And I type "poulet" into the recipe search field
|
|
||||||
Then "TheMealDB" should have been browsed with the query "poulet"
|
|
||||||
When I click the button "Marmiton"
|
|
||||||
Then the recipe search field should contain "poulet"
|
|
||||||
And "Marmiton" should have been browsed with the query "poulet"
|
|
||||||
|
|
||||||
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
|
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
|
||||||
Given the recipe catalog contains nothing
|
Given the recipe catalog contains nothing
|
||||||
And the sources reference list has options
|
And the sources reference list has options
|
||||||
|
|
|
||||||
|
|
@ -56,60 +56,6 @@ Given("the household has enabled TheMealDB", () => {
|
||||||
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
||||||
});
|
});
|
||||||
|
|
||||||
Given("the household has enabled TheMealDB and Marmiton", () => {
|
|
||||||
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1, 2] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Issue #133 — the search field is shared across every source tab. Both
|
|
||||||
// sources answer `browse` the same way and each request is aliased so a
|
|
||||||
// scenario can assert which `query` reached which source.
|
|
||||||
Given("browsing any enabled source echoes back its query", () => {
|
|
||||||
const reply = (req: { url: string; reply: (r: unknown) => void }) => {
|
|
||||||
const query = new URL(req.url).searchParams.get("query") ?? "";
|
|
||||||
req.reply({
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
externalId: `${query || "all"}-1`,
|
|
||||||
title: query ? `Résultat « ${query} »` : "Tout le catalogue",
|
|
||||||
picture: null,
|
|
||||||
url: "https://example.test/1",
|
|
||||||
alreadyImported: false,
|
|
||||||
recipeId: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nextCursor: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
cy.intercept("GET", "**/sources/theMealDb/browse*", reply).as("browseTheMealDb");
|
|
||||||
cy.intercept("GET", "**/sources/marmiton/browse*", reply).as("browseMarmiton");
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I type {string} into the recipe search field", (text: string) => {
|
|
||||||
cy.get(".recipes-page__search").clear().type(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the recipe search field should contain {string}", (text: string) => {
|
|
||||||
cy.get(".recipes-page__search").should("have.value", text);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"{string} should have been browsed with the query {string}",
|
|
||||||
(source: string, query: string) => {
|
|
||||||
const alias = source === "Marmiton" ? "@browseMarmiton.all" : "@browseTheMealDb.all";
|
|
||||||
// `.all` + a retried `.should` — an empty-query browse fires first (tab
|
|
||||||
// opened before anything was typed) and React StrictMode double-invokes
|
|
||||||
// the fetch effect in the dev server, so assert "one of the requests
|
|
||||||
// carried the term" rather than waiting on a specific occurrence.
|
|
||||||
cy.get(alias).should((calls: { request: { url: string } }[]) => {
|
|
||||||
const hit = calls.some((c) => c.request.url.includes(`query=${encodeURIComponent(query)}`));
|
|
||||||
expect(hit, `a ${source} browse used query="${query}"`).to.equal(true);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Given("browsing TheMealDB returns some items", () => {
|
Given("browsing TheMealDB returns some items", () => {
|
||||||
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@
|
||||||
// The favorite-toggle and delete-recipe journeys moved to recipes.feature —
|
// The favorite-toggle and delete-recipe journeys moved to recipes.feature —
|
||||||
// this file now only covers catalog browsing/display.
|
// this file now only covers catalog browsing/display.
|
||||||
|
|
||||||
import { PHONE_VIEWPORT } from "../support/viewports";
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
const authenticatedProfile = {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: "Alice",
|
firstName: "Alice",
|
||||||
|
|
@ -462,38 +460,3 @@ describe("Recipe catalog", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ticket #90 — `.recipe-table-wrap` had no horizontal-scroll guard at all
|
|
||||||
// (unlike the planning grid's own wrapper), so a narrow viewport just let
|
|
||||||
// the browser compress every column instead of scrolling. Below the
|
|
||||||
// tablet breakpoint (1024px) the master-detail split already collapses to
|
|
||||||
// one column (recipes.scss's own `bp.tablet-down` rule) and gives the
|
|
||||||
// table the full page width — still comfortably under `.recipe-table`'s
|
|
||||||
// own 36rem floor on a phone, which is what this covers.
|
|
||||||
describe("Recipe catalog (phone)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.setViewport("phone");
|
|
||||||
interceptAuth();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the table on its own horizontal scroll, at full width, instead of squeezing its columns", () => {
|
|
||||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
|
|
||||||
|
|
||||||
cy.visit("/recettes");
|
|
||||||
|
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
|
||||||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
|
||||||
|
|
||||||
cy.get(".recipe-table-wrap").should(($el) => {
|
|
||||||
const s = getComputedStyle($el[0]);
|
|
||||||
expect(s.overflowX).to.be.oneOf(["auto", "scroll"]);
|
|
||||||
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ...and that overflow stays *inside* the wrapper — same global rule
|
|
||||||
// every other page is held to.
|
|
||||||
cy.document()
|
|
||||||
.its("documentElement.scrollWidth")
|
|
||||||
.should("be.lte", PHONE_VIEWPORT.width + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
||||||
// .github/workflows/ci.yml). apps/api's own Mocha suite covers the real
|
|
||||||
// 401 behavior (an expired/invalid JWT -> `require-auth` -> 401
|
|
||||||
// NOT_AUTHENTICATED); this only covers the *web client's* reaction to it.
|
|
||||||
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: 1,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Issue #108 — a session that expired (or was invalidated) server-side
|
|
||||||
// mid-use only took effect on the next full reload: any other request just
|
|
||||||
// surfaced a local error while the authenticated UI stayed put. `ApiClient`
|
|
||||||
// now reacts to a `401 NOT_AUTHENTICATED` on any request other than the
|
|
||||||
// initial `/auth/me` probe by dropping the session and bouncing to
|
|
||||||
// `/login`.
|
|
||||||
describe("Session expiry", () => {
|
|
||||||
it("bounces to /login when a request 401s mid-session", () => {
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
// Logged in, planning loads fine.
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
cy.wait("@getPlanning");
|
|
||||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
|
||||||
|
|
||||||
// The session dies server-side: every endpoint now 401s, including the
|
|
||||||
// `/auth/me` probe a fresh load would make.
|
|
||||||
const unauthenticated = {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" },
|
|
||||||
};
|
|
||||||
cy.intercept("GET", "**/auth/me", unauthenticated);
|
|
||||||
cy.intercept("GET", /\/planning\?/, unauthenticated);
|
|
||||||
|
|
||||||
// The next planning fetch (a week-nav click) 401s -> bounced to /login.
|
|
||||||
cy.get(".week-nav__arrow").last().click();
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
|
|
||||||
// ...and it's a real logout: reloading a guarded route redirects again
|
|
||||||
// rather than showing stale authed content.
|
|
||||||
cy.visit("/recettes");
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not bounce a logged-out visitor — the initial /auth/me 401 is the normal state", () => {
|
|
||||||
cy.intercept("GET", "**/auth/me", {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { code: ErrorCode.NOT_AUTHENTICATED, message: "not authenticated" },
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/");
|
|
||||||
// `/` for a logged-out visitor is the public landing page (issue #41),
|
|
||||||
// not a redirect to /login — the `/auth/me` probe 401 must not trip the
|
|
||||||
// session-expiry handler.
|
|
||||||
cy.contains("a", "Se connecter").should("be.visible");
|
|
||||||
cy.url().should("not.include", "/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
// Plain layout-mode spec (not Gherkin) for ShoppingListPage's own
|
|
||||||
// phone-viewport pass (issue #90) — the actual grouped-list rendering,
|
|
||||||
// aggregation and week-navigation journey is already covered end-to-end by
|
|
||||||
// shopping-list.feature; this only exercises the responsive change added
|
|
||||||
// here. Mocks the API via cy.intercept — this job doesn't run a live
|
|
||||||
// backend (see .github/workflows/ci.yml).
|
|
||||||
|
|
||||||
import { PHONE_VIEWPORT } from "../support/viewports";
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: 1,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Same minimal-fixture convention as shopping-list.ts's own `buildShoppingListItem` step-def helper — only the fields this page actually reads. */
|
|
||||||
function shoppingListItem(id: number, ingredientKey: string, icon: string, category: string) {
|
|
||||||
return {
|
|
||||||
ingredient: {
|
|
||||||
id,
|
|
||||||
key: ingredientKey,
|
|
||||||
icon,
|
|
||||||
category,
|
|
||||||
subcategory: category,
|
|
||||||
reproducible: false,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
},
|
|
||||||
quantity: 2,
|
|
||||||
unit: { id, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Shopping list page (phone)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.setViewport("phone");
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives each row a taller, more legible tap target than on desktop, with no horizontal body scroll", () => {
|
|
||||||
cy.intercept("GET", /\/shopping-list\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
startDate: "2026-08-17",
|
|
||||||
finishDate: "2026-08-23",
|
|
||||||
items: [shoppingListItem(1, "egg", "EGG", "dairyAndCheese")],
|
|
||||||
},
|
|
||||||
}).as("getList");
|
|
||||||
|
|
||||||
cy.visit("/liste-de-courses");
|
|
||||||
cy.wait("@getList");
|
|
||||||
|
|
||||||
// The interactive row is the `CheckboxOption` label (issue #109), not
|
|
||||||
// the `<li>` wrapper. --space-md (16px) padding on phone vs. the desktop
|
|
||||||
// default --space-sm (8px), plus a ≥44px min-height tap target.
|
|
||||||
cy.get(".shopping-list__item-check").should(($el) => {
|
|
||||||
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
|
|
||||||
expect($el[0].getBoundingClientRect().height).to.be.gte(44);
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.document()
|
|
||||||
.its("documentElement.scrollWidth")
|
|
||||||
.should("be.lte", PHONE_VIEWPORT.width + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -22,11 +22,6 @@ Feature: Shopping list
|
||||||
And the shopping list should show "Tomate" at quantity "400 g"
|
And the shopping list should show "Tomate" at quantity "400 g"
|
||||||
And the shopping list should show "Oeuf" at quantity "6 unité"
|
And the shopping list should show "Oeuf" at quantity "6 unité"
|
||||||
|
|
||||||
Scenario: A « selon le goût » seasoning line shows no quantity (issue #128)
|
|
||||||
Given the shopping list for "2026-08-17" has a « selon le goût » line for "salt"
|
|
||||||
When I visit "/liste-de-courses"
|
|
||||||
Then the shopping list should show "Sel" at quantity "selon le goût"
|
|
||||||
|
|
||||||
Scenario: Navigating to another week fetches and shows that week's own list
|
Scenario: Navigating to another week fetches and shows that week's own list
|
||||||
Given the shopping list for "2026-08-17" is empty
|
Given the shopping list for "2026-08-17" is empty
|
||||||
And the shopping list for "2026-08-24" contains:
|
And the shopping list for "2026-08-24" contains:
|
||||||
|
|
@ -35,15 +30,3 @@ Feature: Shopping list
|
||||||
When I visit "/liste-de-courses"
|
When I visit "/liste-de-courses"
|
||||||
And I click the next week arrow
|
And I click the next week arrow
|
||||||
Then the shopping list should show "Oignon" at quantity "1 kg"
|
Then the shopping list should show "Oignon" at quantity "1 kg"
|
||||||
|
|
||||||
Scenario: Ticking an item marks it as in the cart, and the tick survives a reload
|
|
||||||
Given the shopping list for "2026-08-17" contains:
|
|
||||||
| ingredientKey | icon | category | quantity | unitKey |
|
|
||||||
| tomato | VEGETABLE | freshProduce | 400 | gram |
|
|
||||||
| onion | VEGETABLE | freshProduce | 2 | piece |
|
|
||||||
When I visit "/liste-de-courses"
|
|
||||||
And I tick the shopping list item "Tomate"
|
|
||||||
Then the shopping list item "Tomate" should be ticked
|
|
||||||
And the shopping list item "Oignon" should not be ticked
|
|
||||||
When I reload the page
|
|
||||||
Then the shopping list item "Tomate" should be ticked
|
|
||||||
|
|
|
||||||
|
|
@ -43,38 +43,6 @@ Given("the shopping list for {string} contains:", (date: string, dataTable: Data
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A "selon le goût" line — `quantity`/`unit` both null (issue #128). Its own
|
|
||||||
// step rather than a `buildShoppingListItem` data-table column so that
|
|
||||||
// helper's `quantity`/`unitKey` contract stays required for every other row.
|
|
||||||
Given(
|
|
||||||
"the shopping list for {string} has a « selon le goût » line for {string}",
|
|
||||||
(date: string, ingredientKey: string) => {
|
|
||||||
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
startDate: date,
|
|
||||||
finishDate: date,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
ingredient: {
|
|
||||||
id: 1,
|
|
||||||
key: ingredientKey,
|
|
||||||
icon: "JAR",
|
|
||||||
category: "condimentsAndSpices",
|
|
||||||
subcategory: "spices",
|
|
||||||
reproducible: false,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
},
|
|
||||||
quantity: null,
|
|
||||||
unit: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
||||||
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
||||||
// the prev/next arrows directly by class, same approach here.
|
// the prev/next arrows directly by class, same approach here.
|
||||||
|
|
@ -102,27 +70,3 @@ Then(
|
||||||
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
|
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Issue #109 — a line can be ticked off as "in the cart" (local, per-week,
|
|
||||||
// `localStorage` only — see `shopping-list-progress.ts`).
|
|
||||||
When("I tick the shopping list item {string}", (name: string) => {
|
|
||||||
cy.contains(".shopping-list__item", name).find("label.shopping-list__item-check").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the shopping list item {string} should be ticked", (name: string) => {
|
|
||||||
cy.contains(".shopping-list__item", name)
|
|
||||||
.should("have.class", "shopping-list__item--checked")
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.should("be.checked");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the shopping list item {string} should not be ticked", (name: string) => {
|
|
||||||
cy.contains(".shopping-list__item", name)
|
|
||||||
.should("not.have.class", "shopping-list__item--checked")
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.should("not.be.checked");
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I reload the page", () => {
|
|
||||||
cy.reload();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ describe("Sidebar — settings menu and account menu", () => {
|
||||||
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
|
cy.get(".app-sidebar__nav a").should("not.contain", "Foyer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals the settings pages behind the Paramètres toggle", () => {
|
it("reveals the four settings pages behind the Paramètres toggle", () => {
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
||||||
cy.contains("a", "Compte").should("not.exist");
|
cy.contains("a", "Compte").should("not.exist");
|
||||||
|
|
@ -43,9 +43,6 @@ describe("Sidebar — settings menu and account menu", () => {
|
||||||
"href",
|
"href",
|
||||||
"/parametres/preferences-utilisateur",
|
"/parametres/preferences-utilisateur",
|
||||||
);
|
);
|
||||||
// "Paramètres d'application" is packaged-app only (issue #107) — Cypress
|
|
||||||
// runs as a plain web browser, so it's filtered out here.
|
|
||||||
cy.contains("a", "Paramètres d'application").should("not.exist");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the account menu from the greeting and links to Mon compte", () => {
|
it("opens the account menu from the greeting and links to Mon compte", () => {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { mount } from "cypress/react18";
|
||||||
// components (Checkbox, Radio, Dialog…) are styled through it, not their
|
// components (Checkbox, Radio, Dialog…) are styled through it, not their
|
||||||
// own scoped CSS, so mounting one without it would test unstyled markup.
|
// own scoped CSS, so mounting one without it would test unstyled markup.
|
||||||
import "../../src/styles/global.scss";
|
import "../../src/styles/global.scss";
|
||||||
// Registers `cy.setViewport("phone" | "tablet")` — see viewports.ts.
|
|
||||||
import "./viewports";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
// Cypress support file — global config and custom commands will go here
|
// Cypress support file — global config and custom commands will go here
|
||||||
// as features (and their e2e specs) get added.
|
// as features (and their e2e specs) get added.
|
||||||
import "./viewports";
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ Given("the allergies reference list is empty", () => {
|
||||||
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// An onboarding scenario whose household step *creates* a household then
|
// Every onboarding scenario that reaches the household step also reaches
|
||||||
// reaches `/onboarding/sources` (see `OnboardingHouseholdPage`'s
|
// `/onboarding/sources` right after (when a household got created/joined —
|
||||||
// `goToNextStep`), which reads this before self-skipping straight to the
|
// see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before
|
||||||
// home page if it comes back empty.
|
// self-skipping to `/onboarding/allergenes` if it comes back empty.
|
||||||
Given("the sources reference list is empty", () => {
|
Given("the sources reference list is empty", () => {
|
||||||
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
// Named viewport presets for phone/tablet coverage — the mobile/tablet UI
|
|
||||||
// chantier's testing convention (ticket #83), shared by both the e2e and
|
|
||||||
// component support files (see e2e.ts/component.ts, each of which imports
|
|
||||||
// this once for its side effect of registering `cy.setViewport`).
|
|
||||||
//
|
|
||||||
// Sized from real, representative Android devices rather than Cypress's own
|
|
||||||
// built-in presets (`cy.viewport("iphone-x")` etc.) — this app is
|
|
||||||
// Android-only (see the root README), so an Apple-named preset would be a
|
|
||||||
// odd source of truth to borrow pixel values from. Comfortably inside
|
|
||||||
// `_breakpoints.scss`'s `$phone`/`$tablet` bands (not flush against either
|
|
||||||
// edge) so a test genuinely exercises "a phone" / "a tablet", not the exact
|
|
||||||
// boundary pixel.
|
|
||||||
export const PHONE_VIEWPORT = { width: 390, height: 844 } as const;
|
|
||||||
export const TABLET_VIEWPORT = { width: 900, height: 1200 } as const;
|
|
||||||
|
|
||||||
export type ViewportPreset = "phone" | "tablet";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
/** `cy.viewport(width, height)` for one of the mobile/tablet UI chantier's two named presets — see PHONE_VIEWPORT/TABLET_VIEWPORT above for the exact values. */
|
|
||||||
setViewport(preset: ViewportPreset): Chainable<void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Cypress.Commands.add("setViewport", (preset: ViewportPreset) => {
|
|
||||||
const { width, height } = preset === "phone" ? PHONE_VIEWPORT : TABLET_VIEWPORT;
|
|
||||||
cy.viewport(width, height);
|
|
||||||
});
|
|
||||||
|
|
@ -3,15 +3,6 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!--
|
|
||||||
Tints the mobile browser's own chrome (address bar) to match the app,
|
|
||||||
per system light/dark — same palette as the native app's status bar
|
|
||||||
(apps/web/android/.../res/values*/colors.xml, issue #104). Hex kept in
|
|
||||||
sync with `--color-primary-hover` / `--color-background` (dark) in
|
|
||||||
src/styles/_theme.scss.
|
|
||||||
-->
|
|
||||||
<meta name="theme-color" content="#244f38" media="(prefers-color-scheme: light)" />
|
|
||||||
<meta name="theme-color" content="#14181a" media="(prefers-color-scheme: dark)" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>batchCooking</title>
|
<title>batchCooking</title>
|
||||||
<meta
|
<meta
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.2.3",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { Capacitor } from "@capacitor/core";
|
|
||||||
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
|
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
|
||||||
import { AdminAuthProvider } from "./features/admin/AdminAuthContext";
|
import { AdminAuthProvider } from "./features/admin/AdminAuthContext";
|
||||||
import { RequireAdmin } from "./features/admin/RequireAdmin";
|
import { RequireAdmin } from "./features/admin/RequireAdmin";
|
||||||
import { UpdateBanner } from "./features/app-update/UpdateBanner";
|
|
||||||
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AdminLayout } from "./layouts/AdminLayout";
|
import { AdminLayout } from "./layouts/AdminLayout";
|
||||||
|
|
@ -11,14 +9,11 @@ import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPa
|
||||||
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
|
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
|
||||||
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
|
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
|
||||||
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
|
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
|
||||||
import { MessagesPage as AdminMessagesPage } from "./pages/admin/messages/MessagesPage";
|
|
||||||
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
|
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
|
||||||
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage";
|
||||||
import { UsersPage as AdminUsersPage } from "./pages/admin/users/UsersPage";
|
|
||||||
import { LoginPage } from "./pages/auth/LoginPage";
|
import { LoginPage } from "./pages/auth/LoginPage";
|
||||||
import { SignupPage } from "./pages/auth/SignupPage";
|
import { SignupPage } from "./pages/auth/SignupPage";
|
||||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
||||||
import { DownloadPage } from "./pages/download/DownloadPage";
|
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||||
|
|
@ -28,8 +23,6 @@ import { ImportRecipePage } from "./pages/recipes/ImportRecipePage";
|
||||||
import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
|
import { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
|
||||||
import { RecipesPage } from "./pages/recipes/RecipesPage";
|
import { RecipesPage } from "./pages/recipes/RecipesPage";
|
||||||
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
||||||
import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
|
|
||||||
import { ContactPage } from "./pages/settings/ContactPage";
|
|
||||||
import { CreditsPage } from "./pages/settings/CreditsPage";
|
import { CreditsPage } from "./pages/settings/CreditsPage";
|
||||||
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
||||||
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
||||||
|
|
@ -45,30 +38,20 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* itself redirects to `/login` if needed.
|
* itself redirects to `/login` if needed.
|
||||||
*
|
*
|
||||||
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences
|
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences
|
||||||
* utilisateur/application/crédits) are the settings pages, reachable from
|
* utilisateur/crédits) are the settings pages, reachable from the sidebar's
|
||||||
* the sidebar's bottom "Paramètres" menu and the account menu (see
|
* bottom "Paramètres" menu and the account menu (see `AppLayout`) — nested under
|
||||||
* `AppLayout`) — nested under `AppLayout` like every other authenticated
|
* `AppLayout` like every other authenticated section. `/foyer` is the old,
|
||||||
* section. `/foyer` is the old, pre-split combined page's path; it now
|
* pre-split combined page's path; it now just redirects to
|
||||||
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps
|
* `/parametres/foyer` so an existing bookmark/link keeps working.
|
||||||
* working.
|
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (regime/allergenes/foyer/sources, in that order) is also
|
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
|
||||||
* `RequireAuth`-gated — reached right after signup, once a session already
|
* `RequireAuth`-gated — reached right after signup, once a session already
|
||||||
* exists — but deliberately its own top-level route group, *not* nested
|
* exists — but deliberately its own top-level route group, *not* nested
|
||||||
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
|
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
|
||||||
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
||||||
* `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources`
|
* `/onboarding/sources` is conditional — only reached when the `foyer` step
|
||||||
* is conditional — only reached when the `foyer` step *created* a household
|
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
||||||
* (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending
|
* skipped otherwise, straight to `/onboarding/allergenes`.
|
||||||
* the wizard straight to `/`. Step order and the "sur 3" / "4 sur 4"
|
|
||||||
* indicators live in `pages/onboarding/onboarding-steps.ts`.
|
|
||||||
*
|
|
||||||
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
|
|
||||||
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
|
|
||||||
* `/login`/`/signup`, but deliberately *not* wrapped in
|
|
||||||
* `RedirectIfAuthenticated`: a signed-in visitor still has a legitimate
|
|
||||||
* reason to land here (logged in on the web, app just not installed on
|
|
||||||
* this particular device yet).
|
|
||||||
*
|
*
|
||||||
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
||||||
* triage, off-catalog ingredient review). It's its own top-level group,
|
* triage, off-catalog ingredient review). It's its own top-level group,
|
||||||
|
|
@ -78,17 +61,9 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* never nested under `RequireAuth`. `/admin/login` is the only
|
* never nested under `RequireAuth`. `/admin/login` is the only
|
||||||
* unauthenticated admin route; everything else sits under `RequireAdmin` +
|
* unauthenticated admin route; everything else sits under `RequireAdmin` +
|
||||||
* `AdminLayout`.
|
* `AdminLayout`.
|
||||||
*
|
|
||||||
* {@link UpdateBanner} is mounted here, outside `<Routes>`, rather than in
|
|
||||||
* `AppLayout` — the native app-update check (issue #66) must run on every
|
|
||||||
* app start, session or not, and `AppLayout` only mounts once a visitor is
|
|
||||||
* past `RequireAuth`. It's a `position: fixed` strip and renders nothing on
|
|
||||||
* the web, so mounting it app-wide costs the routed pages no layout.
|
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<UpdateBanner />
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
|
@ -114,22 +89,6 @@ export function App() {
|
||||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||||
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
|
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
|
||||||
{/* Native app only (issue #107): on the web its sections are inert
|
|
||||||
(no "installed version", the update check is a no-op) or have
|
|
||||||
moved to `/telecharger` (the stay-on-web toggle), and the nav
|
|
||||||
entry is hidden — so a stray bookmark redirects rather than
|
|
||||||
landing on an orphaned page (same pattern as `/foyer` below). */}
|
|
||||||
<Route
|
|
||||||
path="/parametres/application"
|
|
||||||
element={
|
|
||||||
Capacitor.isNativePlatform() ? (
|
|
||||||
<AppSettingsPage />
|
|
||||||
) : (
|
|
||||||
<Navigate to="/parametres/compte" replace />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/parametres/contact" element={<ContactPage />} />
|
|
||||||
<Route path="/parametres/credits" element={<CreditsPage />} />
|
<Route path="/parametres/credits" element={<CreditsPage />} />
|
||||||
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
@ -141,14 +100,6 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/onboarding/allergenes"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<OnboardingAllergensPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/foyer"
|
path="/onboarding/foyer"
|
||||||
element={
|
element={
|
||||||
|
|
@ -165,6 +116,14 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/allergenes"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingAllergensPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
|
|
@ -181,7 +140,6 @@ export function App() {
|
||||||
</RedirectIfAuthenticated>
|
</RedirectIfAuthenticated>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/telecharger" element={<DownloadPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AdminAuthProvider>
|
<AdminAuthProvider>
|
||||||
|
|
@ -199,16 +157,13 @@ export function App() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="utilisateurs" element={<AdminUsersPage />} />
|
|
||||||
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
||||||
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
||||||
<Route path="catalogue" element={<AdminCatalogPage />} />
|
<Route path="catalogue" element={<AdminCatalogPage />} />
|
||||||
<Route path="didacticiels" element={<AdminToursPage />} />
|
<Route path="didacticiels" element={<AdminToursPage />} />
|
||||||
<Route path="messages" element={<AdminMessagesPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,17 @@
|
||||||
import {
|
import {
|
||||||
type AdminCreateUserInput,
|
|
||||||
type AdminLoginInput,
|
type AdminLoginInput,
|
||||||
type AdminTourScenarioView,
|
type AdminTourScenarioView,
|
||||||
type AdminUserCreatedView,
|
|
||||||
type AdminUserListItemView,
|
|
||||||
type AdminUserView,
|
type AdminUserView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
type CatalogPlaceholderGroupView,
|
type CatalogPlaceholderGroupView,
|
||||||
type ContactMessageAdminView,
|
|
||||||
type CorrectionAdminView,
|
type CorrectionAdminView,
|
||||||
type CreateForgejoIssueInput,
|
|
||||||
type CreateTourScenarioInput,
|
type CreateTourScenarioInput,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type ForgejoIntegrationStatus,
|
|
||||||
type MarkPlaceholdersReviewedInput,
|
type MarkPlaceholdersReviewedInput,
|
||||||
type MarkTechStepPlaceholdersReviewedInput,
|
|
||||||
type MetricsView,
|
type MetricsView,
|
||||||
type PruneOrphansResultView,
|
type PruneOrphansResultView,
|
||||||
type RetrainRequestInput,
|
type RetrainRequestInput,
|
||||||
type RetrainResultView,
|
type RetrainResultView,
|
||||||
type TechStepPlaceholderView,
|
|
||||||
type TourTargetView,
|
type TourTargetView,
|
||||||
type TrainingDataSnippetView,
|
type TrainingDataSnippetView,
|
||||||
type TrainingSuggestionAdminView,
|
type TrainingSuggestionAdminView,
|
||||||
|
|
@ -134,20 +126,6 @@ export class AdminApiClient {
|
||||||
return this._request(`/metrics?days=${days}`);
|
return this._request(`/metrics?days=${days}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** End-user accounts for the `/admin/utilisateurs` list — newest first, capped server-side at 200. */
|
|
||||||
public getUsers(): Promise<AdminUserListItemView[]> {
|
|
||||||
return this._request("/users");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provisions an end-user account by hand. Returns the created account plus
|
|
||||||
* the one-time temporary password the server generated — show it once,
|
|
||||||
* it's never retrievable again. Rejects with `EMAIL_ALREADY_IN_USE`.
|
|
||||||
*/
|
|
||||||
public createUser(input: AdminCreateUserInput): Promise<AdminUserCreatedView> {
|
|
||||||
return this._request("/users", { method: "POST", body: JSON.stringify(input) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
||||||
public getSuggestions(filters: {
|
public getSuggestions(filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -214,21 +192,6 @@ export class AdminApiClient {
|
||||||
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Free-text techniques users proposed while correcting a step, when the catalog was missing one (issue #130). Same `reviewed` tri-state as {@link getPlaceholders}. */
|
|
||||||
public getTechStepPlaceholders(reviewed?: "true" | "false"): Promise<TechStepPlaceholderView[]> {
|
|
||||||
return this._request(`/catalog/tech-step-placeholders${query({ reviewed })}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Marks the given placeholder technique ids as triaged (`reviewedAt`). */
|
|
||||||
public markTechStepPlaceholdersReviewed(
|
|
||||||
body: MarkTechStepPlaceholdersReviewedInput,
|
|
||||||
): Promise<{ reviewed: number }> {
|
|
||||||
return this._request("/catalog/tech-step-placeholders/mark-reviewed", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
|
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
|
||||||
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
|
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
|
||||||
return this._request("/tours");
|
return this._request("/tours");
|
||||||
|
|
@ -256,37 +219,6 @@ export class AdminApiClient {
|
||||||
public deleteTourScenario(id: number): Promise<void> {
|
public deleteTourScenario(id: number): Promise<void> {
|
||||||
return this._request(`/tours/${id}`, { method: "DELETE" });
|
return this._request(`/tours/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Contact-form messages users have sent (issue #80), newest first, capped
|
|
||||||
* server-side at 200. `type` is an optional single-value filter. Read-only
|
|
||||||
* — this first version has no triage workflow.
|
|
||||||
*/
|
|
||||||
public getContactMessages(filters: { type?: string }): Promise<ContactMessageAdminView[]> {
|
|
||||||
return this._request(`/contact${query(filters)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the "create a Forgejo issue from a message" feature is configured (issue #81) — drives whether the button is shown. */
|
|
||||||
public getForgejoStatus(): Promise<ForgejoIntegrationStatus> {
|
|
||||||
return this._request("/contact/forgejo/status");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Forgejo issue from contact message `id` with the admin's
|
|
||||||
* edited `title`/`body` (issue #81), and returns the message with its new
|
|
||||||
* `forgejoIssueNumber` / `forgejoIssueUrl`. Rejects with
|
|
||||||
* `FORGEJO_NOT_CONFIGURED` / `CONTACT_MESSAGE_ALREADY_LINKED` /
|
|
||||||
* `FORGEJO_REQUEST_FAILED`.
|
|
||||||
*/
|
|
||||||
public createForgejoIssue(
|
|
||||||
id: number,
|
|
||||||
input: CreateForgejoIssueInput,
|
|
||||||
): Promise<ContactMessageAdminView> {
|
|
||||||
return this._request(`/contact/${id}/forgejo-issue`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import {
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
type SourceView,
|
type SourceView,
|
||||||
type StepTechStepCorrectionView,
|
type StepTechStepCorrectionView,
|
||||||
type SubmitContactMessageInput,
|
|
||||||
type SubmitTechStepCorrectionInput,
|
type SubmitTechStepCorrectionInput,
|
||||||
type SubmitTechStepCorrectionResult,
|
type SubmitTechStepCorrectionResult,
|
||||||
type TechStepView,
|
type TechStepView,
|
||||||
|
|
@ -45,7 +44,7 @@ import { noopTokenStore, type TokenStore } from "./token-store";
|
||||||
* in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
|
* in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
|
||||||
* are on different origins there.
|
* are on different origins there.
|
||||||
*/
|
*/
|
||||||
export const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
|
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The token store {@link ApiClient} consults on every request. Defaults to
|
* The token store {@link ApiClient} consults on every request. Defaults to
|
||||||
|
|
@ -66,23 +65,6 @@ export function setTokenStore(store: TokenStore): void {
|
||||||
activeTokenStore = store;
|
activeTokenStore = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by {@link ApiClient._request} the moment any request *other than*
|
|
||||||
* the initial `GET /auth/me` probe comes back `401 NOT_AUTHENTICATED` — i.e.
|
|
||||||
* a session that *was* valid has expired (7-day JWT) or been invalidated
|
|
||||||
* server-side mid-use. The token store is already cleared by the time this
|
|
||||||
* fires; the app-level handler (registered by `AuthProvider`) resets the
|
|
||||||
* in-memory auth state and sends the user to `/login`. `null` until
|
|
||||||
* registered — on the server, in tests, and before `AuthProvider` mounts,
|
|
||||||
* nobody reacts and the `ApiError` just propagates as before (issue #108).
|
|
||||||
*/
|
|
||||||
let onSessionExpired: (() => void) | null = null;
|
|
||||||
|
|
||||||
/** Registers (or, with `null`, clears) the {@link onSessionExpired} handler — `AuthProvider` does both, on mount and unmount. */
|
|
||||||
export function setSessionExpiredHandler(handler: (() => void) | null): void {
|
|
||||||
onSessionExpired = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
||||||
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
||||||
|
|
@ -142,19 +124,6 @@ export class ApiClient {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||||
// A session that was valid has just died server-side (expired JWT,
|
|
||||||
// or a future "log out everywhere"): drop the local token and let
|
|
||||||
// the app react (see `onSessionExpired`). Excludes the initial
|
|
||||||
// `GET /auth/me` probe — a 401 there is the normal "not logged in
|
|
||||||
// yet" state `AuthProvider` already handles, not a mid-use expiry.
|
|
||||||
if (
|
|
||||||
response.status === 401 &&
|
|
||||||
body?.code === ErrorCode.NOT_AUTHENTICATED &&
|
|
||||||
path !== "/auth/me"
|
|
||||||
) {
|
|
||||||
await activeTokenStore.clear();
|
|
||||||
onSessionExpired?.();
|
|
||||||
}
|
|
||||||
// Fallback for a response that couldn't even be parsed as JSON — no
|
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||||
// hardcoded string, always the real enum member.
|
// hardcoded string, always the real enum member.
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
|
|
@ -530,30 +499,6 @@ export class ApiClient {
|
||||||
body: JSON.stringify({ skipped }),
|
body: JSON.stringify({ skipped }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest tagged Android release's version (e.g. `"v1.4.2"`) — used by
|
|
||||||
* {@link useAppUpdateCheck}. Proxied through this API rather than fetched
|
|
||||||
* directly from Forgejo: Forgejo's Releases API sends no CORS headers, so
|
|
||||||
* a browser-side `fetch()` to it fails outright. Public — no session
|
|
||||||
* required.
|
|
||||||
*/
|
|
||||||
public getLatestMobileVersion(): Promise<{ version: string }> {
|
|
||||||
return this._request("/mobile/latest-release");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a contact-form message (bug report / suggestion / other — issue
|
|
||||||
* #80). Session required; the message is stored server-side and read by
|
|
||||||
* admins at `/admin/messages`. Resolves with nothing on success (`204`),
|
|
||||||
* rejects with `VALIDATION_ERROR` if `message` is too short/long. The
|
|
||||||
* server reads the user-agent from the request header itself — the client
|
|
||||||
* only sends `type`, `message`, and the auto-captured `pageUrl` /
|
|
||||||
* `appVersion` context.
|
|
||||||
*/
|
|
||||||
public async submitContactMessage(input: SubmitContactMessageInput): Promise<void> {
|
|
||||||
await this._request("/contact", { method: "POST", body: JSON.stringify(input) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single shared instance — this client is stateless, no need for one per caller. */
|
/** Single shared instance — this client is stateless, no need for one per caller. */
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import "./bottom-sheet.scss";
|
|
||||||
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A native `<dialog>` anchored to the bottom edge instead of centered —
|
|
||||||
* the mobile-native replacement for a small anchored floating popover
|
|
||||||
* (a menu, a short list of actions) that a phone-width screen has no good
|
|
||||||
* place to float. Same open/close mechanics as `Dialog.tsx` (via the
|
|
||||||
* shared `useNativeDialogBehavior`: `showModal()` on mount, Escape/`close`
|
|
||||||
* event, tap-outside-to-dismiss) — only the panel's own position/shape
|
|
||||||
* differs, so both get the browser's focus trap and top-layer stacking for
|
|
||||||
* free.
|
|
||||||
*
|
|
||||||
* First consumer: `AppLayout`'s mobile nav, for the "Plus" tab (Paramètres
|
|
||||||
* + Compte, previously two separate floating dropdowns that had no
|
|
||||||
* viewport-edge handling at all — see the mobile/tablet UI chantier's own
|
|
||||||
* audit). Meant to be reused by the planning popovers (leftovers basket,
|
|
||||||
* week calendar) in a later ticket of that same chantier — kept generic
|
|
||||||
* (title + children, no planning-specific assumptions) for that reason.
|
|
||||||
*
|
|
||||||
* Not used on desktop: a sheet sliding up from the bottom of a full
|
|
||||||
* viewport only reads as "anchored to what you tapped" on a narrow screen
|
|
||||||
* where the tapped control is usually near the bottom already (a bottom
|
|
||||||
* tab bar, a floating action button). Desktop keeps the existing anchored
|
|
||||||
* dropdown/popover pattern for the same triggers — callers decide which to
|
|
||||||
* render (typically via `useIsMobileViewport()`), this component doesn't
|
|
||||||
* check the viewport itself.
|
|
||||||
*/
|
|
||||||
export function BottomSheet({
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
onClose: () => void;
|
|
||||||
title?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const dialogRef = useNativeDialogBehavior(onClose);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<dialog ref={dialogRef} className="bottom-sheet" aria-label={title}>
|
|
||||||
{/* Purely decorative drag-affordance — this sheet closes via
|
|
||||||
tap-outside/Escape/the close button, never an actual drag
|
|
||||||
gesture, but the grabber bar is still the expected visual
|
|
||||||
convention for "this slid up from the bottom" on both Android and
|
|
||||||
iOS, so it's worth keeping even without the gesture behind it. */}
|
|
||||||
<div className="bottom-sheet__grabber" aria-hidden="true" />
|
|
||||||
{title && (
|
|
||||||
<div className="bottom-sheet__header">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bottom-sheet__close"
|
|
||||||
onClick={() => dialogRef.current?.close()}
|
|
||||||
aria-label="Fermer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bottom-sheet__body">{children}</div>
|
|
||||||
</dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
import "./dialog.scss";
|
import "./dialog.scss";
|
||||||
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide modal primitive — a native `<dialog>` (`showModal()`), not a
|
* App-wide modal primitive — a native `<dialog>` (`showModal()`), not a
|
||||||
|
|
@ -12,14 +11,6 @@ import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
|
||||||
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
|
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
|
||||||
* fit inline, hence this.
|
* fit inline, hence this.
|
||||||
*
|
*
|
||||||
* On a phone (`_breakpoints.scss`'s `$phone`) the panel goes edge-to-edge
|
|
||||||
* full screen instead of staying a centered, rounded panel — see
|
|
||||||
* `dialog.scss`'s own comment on why a shrunk desktop dialog isn't enough
|
|
||||||
* on a small screen. `BottomSheet` (this same folder) is the sibling
|
|
||||||
* primitive for a *permanently* bottom-anchored overlay (a menu/popover),
|
|
||||||
* not a full modal — the two share their open/close mechanics via
|
|
||||||
* `useNativeDialogBehavior` but stay visually and semantically distinct.
|
|
||||||
*
|
|
||||||
* Mounted only while open (see `PlanningPage`'s conditional rendering of
|
* Mounted only while open (see `PlanningPage`'s conditional rendering of
|
||||||
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
|
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
|
||||||
* `showModal()` fires once on mount rather than toggling on an `isOpen`
|
* `showModal()` fires once on mount rather than toggling on an `isOpen`
|
||||||
|
|
@ -40,7 +31,56 @@ export function Dialog({
|
||||||
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
|
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const dialogRef = useNativeDialogBehavior(onClose);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
// `close` covers every way a native dialog can close — Escape (which
|
||||||
|
// fires `cancel` first, then `close`) as much as a future
|
||||||
|
// `<form method="dialog">` — so this is the one listener needed to
|
||||||
|
// keep the caller's own "is this open" state (e.g. `PlanningPage`'s
|
||||||
|
// `openSlot`) in sync with it.
|
||||||
|
dialog.addEventListener("close", onClose);
|
||||||
|
return () => dialog.removeEventListener("close", onClose);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
// Attached imperatively (not a JSX `onClick`) since the click-to-close
|
||||||
|
// it implements is already reachable from the keyboard via Escape
|
||||||
|
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
|
||||||
|
// trip the "needs a matching keyboard handler" a11y lint for a
|
||||||
|
// non-interactive element even though one already exists, just not in
|
||||||
|
// a form that lint rule can see.
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
// Re-read from the ref (not the outer `dialog` const) — TS can't
|
||||||
|
// carry that early-return narrowing into a nested function, since it
|
||||||
|
// can't prove the function won't run at some later point where it no
|
||||||
|
// longer holds (even though here, as an event listener on this same
|
||||||
|
// element, it trivially still does).
|
||||||
|
const current = dialogRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
// A click lands on the `<dialog>` element itself both for the
|
||||||
|
// backdrop *and* for its own unfilled padding/margin — comparing
|
||||||
|
// against its content box (not just `e.target`) is what actually
|
||||||
|
// distinguishes "outside the panel" from "on it".
|
||||||
|
const rect = current.getBoundingClientRect();
|
||||||
|
const inside =
|
||||||
|
e.clientX >= rect.left &&
|
||||||
|
e.clientX <= rect.right &&
|
||||||
|
e.clientY >= rect.top &&
|
||||||
|
e.clientY <= rect.bottom;
|
||||||
|
if (!inside) current.close();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("click", handleClick);
|
||||||
|
return () => dialog.removeEventListener("click", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
|
||||||
import { type ChangeEvent, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import "./password-input.scss";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A password `<input>` paired with a show/hide "eye" toggle — the button
|
|
||||||
* swaps the field's `type` between `"password"` and `"text"` so a visitor
|
|
||||||
* can re-read what they typed before submitting. A bare password field
|
|
||||||
* gives no feedback at all, and a mistyped-but-invisible password is one
|
|
||||||
* of the most common causes of a failed login; this makes it checkable
|
|
||||||
* without a password manager.
|
|
||||||
*
|
|
||||||
* Lives in `components/ui/` rather than `features/auth/` because nothing
|
|
||||||
* about it is auth-specific — it is just a password field. Used today by
|
|
||||||
* {@link LoginPage} and {@link SignupPage}.
|
|
||||||
*
|
|
||||||
* Visibility is local state that always starts hidden on mount and is
|
|
||||||
* never persisted: revealing a password is a deliberate, momentary action,
|
|
||||||
* not a preference worth remembering across page loads or sessions.
|
|
||||||
*
|
|
||||||
* The field's own look (border, padding, background) is inherited from the
|
|
||||||
* surrounding context — the same arrangement as {@link CheckboxOption},
|
|
||||||
* which is styled through `global.scss` rather than carrying its own CSS.
|
|
||||||
* The colocated `password-input.scss` only positions the toggle inside the
|
|
||||||
* field and reserves horizontal room for it.
|
|
||||||
*/
|
|
||||||
export function PasswordInput({
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
autoComplete,
|
|
||||||
}: {
|
|
||||||
/** Forwarded to the `<input>` so an external `<label htmlFor>` keeps targeting it. */
|
|
||||||
id: string;
|
|
||||||
/** Controlled value — this component holds no text state of its own. */
|
|
||||||
value: string;
|
|
||||||
/** Called with the new text on each change (event unwrapped, like {@link CheckboxOption}). */
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
/**
|
|
||||||
* Password-manager hint, forwarded verbatim — callers pass
|
|
||||||
* `"current-password"` on a login form, `"new-password"` on signup.
|
|
||||||
*/
|
|
||||||
autoComplete?: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Always hidden on mount — see the component doc comment on why this is
|
|
||||||
// never persisted.
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
|
|
||||||
// The label always names the action the *next* click performs; the
|
|
||||||
// pressed state is carried separately by `aria-pressed` below.
|
|
||||||
const toggleLabel = visible ? t("common.passwordInput.hide") : t("common.passwordInput.show");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="password-input">
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type={visible ? "text" : "password"}
|
|
||||||
value={value}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
||||||
autoComplete={autoComplete}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="password-input__toggle"
|
|
||||||
// `type="button"` (not the implicit `"submit"`) so toggling
|
|
||||||
// visibility never submits the form the field sits in.
|
|
||||||
aria-pressed={visible}
|
|
||||||
aria-label={toggleLabel}
|
|
||||||
title={toggleLabel}
|
|
||||||
onClick={() => setVisible((v) => !v)}
|
|
||||||
>
|
|
||||||
{visible ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// Bottom-anchored panel — see BottomSheet.tsx. A native <dialog> opened via
|
|
||||||
// showModal(), same UA-default reset as dialog.scss, but positioned flush
|
|
||||||
// against the bottom edge instead of centered.
|
|
||||||
|
|
||||||
.bottom-sheet {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 32rem; // caps how wide this gets on a tablet-width viewport
|
|
||||||
max-height: min(80vh, 40rem);
|
|
||||||
// A native <dialog>'s UA default is `position: fixed; inset: 0; margin:
|
|
||||||
// auto` (centered) — overriding just the vertical half of that margin
|
|
||||||
// pins it to the bottom edge while `margin-inline: auto` keeps it
|
|
||||||
// horizontally centered (relevant once `max-width` is narrower than the
|
|
||||||
// viewport, e.g. on a tablet).
|
|
||||||
margin: 0 auto auto;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::backdrop {
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sheet__grabber {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 0.25rem;
|
|
||||||
margin: var(--space-sm) auto;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sheet__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding: 0 var(--space-lg) var(--space-md);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sheet__close {
|
|
||||||
flex: none;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-sheet__body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
// A phone's bottom safe area (gesture bar / rounded corners) sits right
|
|
||||||
// below this sheet's own bottom edge — the extra bottom padding keeps the
|
|
||||||
// last item from ever feeling flush against it.
|
|
||||||
padding: 0 var(--space-lg) var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
@ -5,8 +5,6 @@
|
||||||
// since it's one component's styling, same convention as every feature's
|
// since it's one component's styling, same convention as every feature's
|
||||||
// own .scss file (recipes.scss, planning-page.scss, …).
|
// own .scss file (recipes.scss, planning-page.scss, …).
|
||||||
|
|
||||||
@use "../../styles/breakpoints" as bp;
|
|
||||||
|
|
||||||
.dialog-panel {
|
.dialog-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -26,18 +24,14 @@
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A shrunk-but-still-centered panel (the old phone treatment here — just
|
// On a phone the `48rem` cap is irrelevant (the viewport is narrower),
|
||||||
// tighter margins) still wastes a meaningful slice of a ~375px screen on
|
// but the `var(--space-2xl)` top/bottom breathing room is a large slice
|
||||||
// rounded corners + drop shadow + surrounding backdrop, and still needs
|
// of a short screen — tighten it so a form-heavy dialog isn't
|
||||||
// its own internal scroll for anything longer than a short form. Full
|
// needlessly cramped, and keep a small side gutter so the panel never
|
||||||
// screen removes all of that: the panel just *is* the screen, so there's
|
// sits edge to edge.
|
||||||
// nothing left to reflow around.
|
@media (max-width: 640px) {
|
||||||
@include bp.phone {
|
max-width: calc(100vw - var(--space-md));
|
||||||
max-width: 100vw;
|
max-height: calc(100vh - var(--space-md));
|
||||||
max-height: 100vh;
|
|
||||||
height: 100vh;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// PasswordInput (components/ui/PasswordInput.tsx) — positions the show/hide
|
|
||||||
// "eye" toggle inside the password field and keeps typed text clear of it.
|
|
||||||
//
|
|
||||||
// The field's own look (border, padding, colours) is inherited from the
|
|
||||||
// surrounding context — e.g. `.auth-card input` in features/auth/auth-form.scss
|
|
||||||
// — exactly as CheckboxOption leans on global.scss. This sheet owns only the
|
|
||||||
// wrapper, the toggle button, and the space the icon needs.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// No `@use` of the theme partial: every token below (--color-*, --space-*) is a
|
|
||||||
// CSS custom property declared on :root in styles/global.scss, available at
|
|
||||||
// runtime without a compile-time import.
|
|
||||||
|
|
||||||
.password-input {
|
|
||||||
// Positioning context for the absolutely-placed toggle. `display: flex` lets
|
|
||||||
// the inner <input> fill the row on its own (it replaces a bare <input> that
|
|
||||||
// used to be a direct flex child of the form column).
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
// Keep the caret and text from sliding under the icon: field padding on the
|
|
||||||
// left + the ~20px glyph + matching breathing room on the right.
|
|
||||||
padding-right: calc(var(--space-sm) + 20px + var(--space-sm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The eye button: sits flush inside the field's right edge, full height so its
|
|
||||||
// hit area matches the input, no surface of its own so it reads as part of the
|
|
||||||
// field rather than a second control.
|
|
||||||
.password-input__toggle {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 var(--space-sm);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// lucide-react renders a 24×24 <svg> by default — bring it closer to the
|
|
||||||
// field's text size.
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mechanics every native `<dialog>`-based overlay in this app needs,
|
|
||||||
* factored out of `Dialog.tsx` once `BottomSheet.tsx` needed the exact same
|
|
||||||
* three behaviors with only its CSS differing: open via `showModal()` on
|
|
||||||
* mount, forward the native `close` event (covers Escape and any future
|
|
||||||
* `<form method="dialog">`) to the caller's own `onClose`, and treat a
|
|
||||||
* click that lands outside the panel's own content box as "close" (a click
|
|
||||||
* on the `<dialog>` element itself, since it fills the viewport, is what a
|
|
||||||
* backdrop click looks like — there's no separate `::backdrop` element to
|
|
||||||
* attach a listener to).
|
|
||||||
*
|
|
||||||
* Returns just the ref — callers still own their own JSX/className, this
|
|
||||||
* only owns the imperative wiring. No explicit return type annotation on
|
|
||||||
* purpose: `useRef<HTMLDialogElement>(null)`'s inferred type is exactly
|
|
||||||
* what JSX's `ref` prop expects, but spelling that same type out by hand
|
|
||||||
* (`RefObject<HTMLDialogElement | null>`) is subtly different and doesn't
|
|
||||||
* satisfy it — let inference carry it through instead of re-typing it.
|
|
||||||
*/
|
|
||||||
export function useNativeDialogBehavior(onClose: () => void) {
|
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dialogRef.current?.showModal();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
dialog.addEventListener("close", onClose);
|
|
||||||
return () => dialog.removeEventListener("close", onClose);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
// Attached imperatively (not a JSX `onClick`) since the click-to-close
|
|
||||||
// it implements is already reachable from the keyboard via Escape
|
|
||||||
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
|
|
||||||
// trip the "needs a matching keyboard handler" a11y lint for a
|
|
||||||
// non-interactive element even though one already exists, just not in
|
|
||||||
// a form that lint rule can see.
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
// Re-read from the ref (not the outer `dialog` const) — TS can't
|
|
||||||
// carry that early-return narrowing into a nested function, since it
|
|
||||||
// can't prove the function won't run at some later point where it no
|
|
||||||
// longer holds (even though here, as an event listener on this same
|
|
||||||
// element, it trivially still does).
|
|
||||||
const current = dialogRef.current;
|
|
||||||
if (!current) return;
|
|
||||||
// A click lands on the `<dialog>` element itself both for the
|
|
||||||
// backdrop *and* for its own unfilled padding/margin — comparing
|
|
||||||
// against its content box (not just `e.target`) is what actually
|
|
||||||
// distinguishes "outside the panel" from "on it".
|
|
||||||
const rect = current.getBoundingClientRect();
|
|
||||||
const inside =
|
|
||||||
e.clientX >= rect.left &&
|
|
||||||
e.clientX <= rect.right &&
|
|
||||||
e.clientY >= rect.top &&
|
|
||||||
e.clientY <= rect.bottom;
|
|
||||||
if (!inside) current.close();
|
|
||||||
}
|
|
||||||
dialog.addEventListener("click", handleClick);
|
|
||||||
return () => dialog.removeEventListener("click", handleClick);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return dialogRef;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { API_BASE_URL } from "../../api/client";
|
|
||||||
import "./update-banner.scss";
|
import "./update-banner.scss";
|
||||||
import { useAppUpdateCheck } from "./useAppUpdateCheck";
|
import { useAppUpdateCheck } from "./useAppUpdateCheck";
|
||||||
|
|
||||||
|
|
@ -11,13 +10,11 @@ import { useAppUpdateCheck } from "./useAppUpdateCheck";
|
||||||
* `<dialog>`: a version check is informative, never something that should
|
* `<dialog>`: a version check is informative, never something that should
|
||||||
* block the rest of the page.
|
* block the rest of the page.
|
||||||
*
|
*
|
||||||
* "Mettre à jour" opens this API's own `/mobile/download` (a plain
|
* "Mettre à jour" opens the download link externally (the system browser
|
||||||
* redirect straight to the latest release's actual `.apk`, resolved
|
* handles the download + Android's own install prompt) rather than an
|
||||||
* server-side — see `mobile.service.ts`) externally, rather than an in-app
|
* in-app `PackageInstaller` flow — no `REQUEST_INSTALL_PACKAGES` permission,
|
||||||
* `PackageInstaller` flow: no `REQUEST_INSTALL_PACKAGES` permission, no
|
* no extra native code, for a marginal UX gain (see issue #66's own
|
||||||
* extra native code, for a marginal UX gain (see issue #66's own
|
* hors-scope).
|
||||||
* hors-scope). The system browser follows the redirect and handles the
|
|
||||||
* download + Android's own install prompt.
|
|
||||||
*/
|
*/
|
||||||
export function UpdateBanner() {
|
export function UpdateBanner() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -33,7 +30,7 @@ export function UpdateBanner() {
|
||||||
<div className="update-banner__actions">
|
<div className="update-banner__actions">
|
||||||
<a
|
<a
|
||||||
className="update-banner__update"
|
className="update-banner__update"
|
||||||
href={`${API_BASE_URL}/mobile/download`}
|
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest/download/batchcooking.apk"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,18 @@
|
||||||
// Styles for UpdateBanner — a discreet, dismissible strip pinned to the top
|
// Styles for UpdateBanner — a discreet, dismissible strip above the routed
|
||||||
// of the screen. Mounted app-wide in App.tsx (outside `<Routes>`) so the
|
// page content (see AppLayout.tsx). Uses --color-warning the same way
|
||||||
// native update check runs session or not, hence `position: fixed` rather
|
// source-item-preview__hint does elsewhere in the app: "worth noticing",
|
||||||
// than the old in-flow placement inside `.app-content`: this way it never
|
|
||||||
// enters any routed page's layout / height math (`.app-layout` is
|
|
||||||
// `height: 100vh; overflow: hidden`), it just overlays. Native-only —
|
|
||||||
// renders nothing on the web (see UpdateBanner.tsx). Uses --color-warning
|
|
||||||
// the same way source-item-preview__hint does elsewhere: "worth noticing",
|
|
||||||
// not an error.
|
// not an error.
|
||||||
.update-banner {
|
.update-banner {
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
// Clears the Android status bar when the WebView draws under it.
|
|
||||||
padding-top: calc(var(--space-sm) + env(safe-area-inset-top, 0px));
|
|
||||||
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
||||||
border-bottom: 1px solid var(--color-warning);
|
border: 1px solid var(--color-warning);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue