Compare commits
6 commits
8ea4c3fd10
...
90a7de3388
| Author | SHA1 | Date | |
|---|---|---|---|
| 90a7de3388 | |||
| 445dda260a | |||
| 30b148694c | |||
| 3ea5f822f1 | |||
| d245f31ab6 | |||
| 8bda6c5f82 |
31 changed files with 6644 additions and 960 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -55,3 +55,6 @@ venv/
|
|||
|
||||
# Docmost fork — historique git separe (depth=1 clone), futur submodule sur fork Acadenice
|
||||
docmost/
|
||||
|
||||
# BYAN session outputs — notes de travail internes, ne jamais commit (contiennent parfois credentials)
|
||||
_byan-output/
|
||||
|
|
|
|||
|
|
@ -1,655 +0,0 @@
|
|||
# SESSION RESUME — formation-hub Acadenice (last update post Patch 017 — 0 TS errors, 0 test failures)
|
||||
|
||||
## RECAP SESSION 2026-05-07 (lecture obligatoire post-/compact)
|
||||
|
||||
### Pivot strategique majeur acte
|
||||
DocAdenice n'est plus un outil metier formation-hub mais un **produit Notion-like generique**. Le bridge a ete refactor (R1) pour supprimer l'ontologie metier (Personne/Formation/Bloc/Module/Attribution/Client/Projet/Tache/Intervention) au profit de routes generiques `/api/v1/tables/*`. Le metier formation-hub vit dans `examples/acadenice-formation-hub/`.
|
||||
|
||||
### Memoire perso a jour
|
||||
- `feedback_no_mvp.md` : Corentin refuse les MVP / shortcuts. Production-like des le jour 1.
|
||||
- `user_role.md` : ancien conseil "MVP first" marque OBSOLETE.
|
||||
- `MEMORY.md` index cree.
|
||||
|
||||
### Etat des chantiers (commits, ordres chronologique de la session)
|
||||
|
||||
**Bridge formation-hub (`bridge/`, push origin+selfhost)** :
|
||||
```
|
||||
e969545 R3.1.e Playwright e2e cross-stack (compose+7 scenarios+CI workflow) — R3.1 ENTIEREMENT TERMINE
|
||||
c998c0d R3.1.b SSE realtime stream (Redis Streams pub/sub, Last-Event-ID, heartbeat) — 380 tests
|
||||
95089c4 R3.1.a views endpoints (GET /views/table/:id + GET /views/:id/data) — 336 tests
|
||||
a79c51e R2.3b bridge accepte JWT HMAC DocAdenice via DOCMOST_APP_SECRET
|
||||
2ed73fa R1 refactor proxy generique style Notion
|
||||
0cf6533 Bloc 5 rate limit + cache invalidation cote writes
|
||||
571f5c3 Bloc 4 OIDC-ready (Authentik JWKS + service tokens)
|
||||
8b42cbc chore docmost upstream clone + rename setup
|
||||
022b1ee Bloc 7 webhooks Baserow + Docmost stub (HMAC + idempotence)
|
||||
c4f087b Bloc 6 tests integration adapters via testcontainers
|
||||
```
|
||||
Bridge state : 380/380 tests verts (+44 R3.1.a +6 retry/fake-redis, +11 SSE route, +8 baserow-handler SSE), coverage globale ~90% lines, 3 sources d'auth Bearer (brg_*, RS256 Authentik, HS256 DocAdenice). event.ts 100%, event-bus.ts 100% lines/94.44% branches, events.ts 78.23% lines (onError Hono safety net + heartbeat-closed guard non atteignables sans serveur HTTP reel). Thresholds vitest.config.ts mis a jour pour les 3 nouveaux fichiers.
|
||||
|
||||
**Fork DocAdenice (`docmost/`, gitignored, branche `acadenice/main`, local-only)** :
|
||||
```
|
||||
4cf0408 fix(acadenice): resolve test suite failures across R3 sub-blocks (Patch 017)
|
||||
- 17 server specs converted from vitest to Jest (vi -> jest globals)
|
||||
- jest.mock stubs for ESM-only prosemirror/html and collaboration modules
|
||||
- Zod v4 strict UUID fixtures fixed (version byte [1-8][89abAB] required)
|
||||
- JwtAuthGuard.overrideGuard added to all controller specs
|
||||
- jest.Mock explicit types to prevent 'never' arg inference
|
||||
- Client: deleted vitest.config.ts (CJS), kept vitest.config.mts (ESM)
|
||||
- Client: global mocks for @excalidraw/excalidraw and @/main.tsx
|
||||
- Result: client 38/38 suites 313/313 tests, server acadenice 21/21 suites 210/210 tests, 0 TS errors
|
||||
be951a2 feat(acadenice): add inline comments threads for R3.8 (30 tests, Patch 016)
|
||||
7d076aa feat(acadenice): add mentions notifications system for R3.7 (45 tests, Patch 015)
|
||||
614533f feat(acadenice): add page templates system for R3.6 (65 tests, Patch 014)
|
||||
aac0149 feat(acadenice): add graph view UI for R3.5.2 (58 tests, Patch 013)
|
||||
5f7271d feat(acadenice): add graph endpoint for R3.5.1 (35 tests, Patch 012)
|
||||
9be979e feat(acadenice): add dual editor (WYSIWYG + markdown source) for R3.4 (77 tests)
|
||||
ba18a34 docs(fork): update ACADENICE_PATCHES.md Patch 010 for R3.3
|
||||
4e2af88 feat(acadenice): add custom slash commands system for R3.3 (183 tests total)
|
||||
8cd57f9 docs(fork): update ACADENICE_PATCHES.md Patch 009 for R3.2
|
||||
2fc310a feat(acadenice): add bidirectional backlinks + wikilinks for R3.2 (135 tests total)
|
||||
ba8d867 test(e2e): add data-testid attributes for Playwright e2e (Patch 008 R3.1.e)
|
||||
ea00386 docs(fork): update ACADENICE_PATCHES.md Patch 007 for R3.1.d
|
||||
f3fae2a R3.1.d kanban + calendar renderers + inline edit (33 tests, total 96)
|
||||
71c2aba R3.1.c database-view Tiptap extension + renderer table + slash /database + SSE hook (41 tests)
|
||||
4d8bd25 R2.3a /api/acadenice/permissions/me + frontend hook React Query propre
|
||||
022add9 R2.2 frontend pages settings RBAC (PermissionMatrix, sidebar, i18n FR+EN)
|
||||
bcd8611 R2.1 backend RBAC dynamique (catalogue 22 perms, 5 roles seed, JWT enrichi)
|
||||
06c46f7 fix scopes Authentik (groups dans profile, pas un scope standard)
|
||||
07d0b66 Bloc 4b OIDC client Authentik via openid-client v6.8.2
|
||||
efa2644 rebrand DocAdenice (titres + emails, identifiants techniques KEEP)
|
||||
```
|
||||
|
||||
### Ce qui marche end-to-end (en local)
|
||||
- Bridge expose `/api/v1/tables/*` (CRUD generique Baserow)
|
||||
- Frontend DocAdenice `/settings/roles` + matrix permissions + assignation users
|
||||
- JWT DocAdenice enrichi avec `acadenice_permissions[]` au sign
|
||||
- Bridge consume le claim direct (pas de mapping)
|
||||
- 3 modes auth Bearer cohabitent
|
||||
|
||||
### Catalogue 30 permissions atomiques (en code TS, fork) — mis a jour R3.8
|
||||
```
|
||||
pages:read|write|delete|share, space:read|create|write|delete|invite,
|
||||
tables:list|create|write|delete, rows:read|write|delete,
|
||||
attachments:upload|delete, users:invite|write|delete, roles:manage,
|
||||
slash_commands:manage (R3.3),
|
||||
templates:read|create|manage (R3.6),
|
||||
comments:read|write|resolve|moderate (R3.8 - nouveaux),
|
||||
admin:*
|
||||
```
|
||||
|
||||
### 5 roles classiques pre-seed (`is_system_role=true`)
|
||||
Owner=`admin:*`, Admin=tout sauf `*:delete` et `roles:manage`, Editor, Member, Guest.
|
||||
|
||||
### Suite immediate : R3 — Tiptap node-views Notion-like (5 sous-blocs)
|
||||
- **R3.1** database-view inline (embed une table/kanban/calendar Baserow dans une page) — decoupe :
|
||||
- R3.1.a bridge endpoints views (LIVRE 95089c4, 336 tests)
|
||||
- R3.1.b bridge SSE realtime (events Baserow -> stream DocAdenice, invalidation cache) — LIVRE c998c0d, 380 tests
|
||||
- R3.1.c frontend node-view Tiptap + renderer table (TanStack Table v8) + slash `/database` — LIVRE 71c2aba (41 tests)
|
||||
- R3.1.d frontend renderers kanban (@dnd-kit) + calendar (@fullcalendar) + edit inline — LIVRE f3fae2a (33 tests, total 96)
|
||||
- R3.1.e Playwright e2e cross-stack (compose dev Postgres+Redis+Baserow+bridge+DocAdenice, 7 scenarios) — LIVRE e969545 (formation-hub) + ba8d867 (fork testids)
|
||||
- **R3.2** backlinks bidirec (page A reference B → B liste les references entrantes) — **LIVRE** `2fc310a`
|
||||
- **R3.3** slash commands custom (declarer ses propres `/foo` extensibles) — **LIVRE** `4e2af88`
|
||||
- **R3.4** dual editor (code raw markdown + WYSIWYG) — **LIVRE** `9be979e`
|
||||
- **R3.5** graph view (style Obsidian / AFFiNE) — visualise les liens entre pages :
|
||||
- **R3.5.1 backend** : `GET /api/acadenice/graph` — **LIVRE** `5f7271d` (35 tests, Patch 012)
|
||||
- **R3.5.2 frontend** : page `/graph` interactif (zoom, pan, drag, click, side panel, search) — **LIVRE** `aac0149` (58 tests, Patch 013)
|
||||
- Depend de R3.2 (backlinks fournit la data)
|
||||
- **R3.5 ENTIEREMENT TERMINE**
|
||||
- **R3.6** templates de pages — **LIVRE** `614533f` (65 tests, Patch 014). Table `acadenice_template`, 5 built-ins seedes, gallery /settings/templates, picker modal sidebar + slash /template, 26 permissions.
|
||||
- **R3.7** mentions `@user` + notifs in-app — **LIVRE** `7d076aa` (45 tests, Patch 015). MentionDetectorService (pure walker), NotificationEmitterService (REST path bridge), AcadeniceNotificationsModule facade, /notifications page, /settings/notifications prefs. Native collab path already active. Poll 30s unread count. No new DB table.
|
||||
- **R3.8** comments inline — **LIVRE** `be951a2` (30 tests, Patch 016). REST resolve facade for page comments (PageCommentResolveService + CollaborationGateway yjs sync). New acadenice_row_comment table + RowCommentService + REST CRUD + React RowCommentsPanel in RowDetailModal tabs. 4 new permissions comments:read|write|resolve|moderate (30 total). i18n FR+EN.
|
||||
- **R3 ENTIEREMENT TERMINE**
|
||||
|
||||
### Mode Loop full autonome (decision 2026-05-08)
|
||||
Loop autonome R3.1.d -> R3.8 termine. Patch 017 fix typecheck post-install pnpm. Etat final : 0 TS error client + server, 313 tests client + 210 tests server + 380 tests bridge tous verts.
|
||||
|
||||
### Questions ouvertes a trancher post-/compact (2026-05-08)
|
||||
|
||||
**Q1 — Strategie test E2E AI-driven (Claude Code / Stagehand / Computer Use)**
|
||||
- Constat : Playwright (R3.1.e livre) couvre les chemins critiques deterministes. Manque coverage exploratoire.
|
||||
- Option A : ajouter `R3.1.f Claude Code smoke` — workflow CI pre-release qui invoke Claude via Agent SDK avec acces Playwright + scenario en markdown. ~$0.50/run Sonnet, ~$2 Opus.
|
||||
- Option B : decaler en R4.x post-release stabilization.
|
||||
- Position complementaire (pas de remplacement Playwright) : Playwright = CI a chaque push deterministe, Claude smoke = nightly creatif decouverte UX.
|
||||
- Decision a prendre : R3.1.f ou R4.x ?
|
||||
|
||||
**Q2 — Repartition Postgres DocAdenice vs Baserow (deja livre, audit possible)**
|
||||
- Regle implicite appliquee R3.2/R3.6/R3.7/R3.8 : Baserow = donnees user-visibles (les "databases" Notion-like que Ludo cree et regarde). Postgres DocAdenice = etat plateforme (auth, RBAC, contenu pages, notifs, comments, templates, backlinks).
|
||||
- 6 tables creees dans Postgres : `acadenice_backlink`, `acadenice_template`, `acadenice_notification`, `acadenice_notification_preferences`, `acadenice_page_comment`, `acadenice_row_comment`. Plus les 3 tables RBAC R2.1.
|
||||
- Justifications : FK CASCADE, latence <50ms requise pour panel backlinks, RBAC Docmost natif applique server-side, pas de pollution workspace Ludo, coherence yjs collab pour comments.
|
||||
- A reverifier ensemble si certaines de ces 6 tables devraient migrer vers Baserow (ex: templates editables comme une table user). Mon avis : non, mais ouvert au debat.
|
||||
|
||||
### TODO connus non bloquants
|
||||
- Hook `WorkspaceService.create` pour seed live RBAC (actuellement seed au prochain boot)
|
||||
- Audit log mutations role/assignation
|
||||
- Mapping group sync OIDC -> acadenice_role (sync user.groups Authentik vers acadenice_user_role)
|
||||
- Pagination liste roles (assume < 100 / workspace)
|
||||
- Section "Members" dans page detail role
|
||||
- Endpoint admin debug `GET /permissions/me/effective?for=<userId>`
|
||||
|
||||
### Push pending au fork
|
||||
Quand un fork remote `acadenice` sera cree (Forgejo ou GitHub fork), push toute la branche `acadenice/main` du repo `docmost/` sur ce remote. Aujourd'hui les commits sont local-only.
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG R2.3b — Bridge accepte JWT HMAC DocAdenice (mode local sans Authentik)
|
||||
|
||||
Date : 2026-05-07
|
||||
Commit local (a pusher manuellement) : voir `git log -1` dans `bridge/`.
|
||||
|
||||
**Pourquoi** : DocAdenice signe ses JWT en HS256 avec `appSecret`. En local sans
|
||||
Authentik branche, le frontend DocAdenice qui call le bridge directement aurait
|
||||
echoue (le bridge ne savait valider que `brg_*` et JWT RS256 Authentik). R2.3b
|
||||
ajoute un troisieme mode au middleware : valider les JWT HS256/384/512 signes
|
||||
par DocAdenice via `DOCMOST_APP_SECRET`.
|
||||
|
||||
**Fichiers crees** :
|
||||
- `bridge/src/middleware/docmost-jwt-verifier.ts` (verifier HMAC + helpers `decodeJwtAlg` + `extractDocmostPermissions`)
|
||||
- `bridge/tests/middleware/docmost-jwt-verifier.test.ts` (28 tests unitaires)
|
||||
|
||||
**Fichiers modifies** :
|
||||
- `bridge/src/lib/config.ts` : 3 nouvelles vars (`docmostAppSecret`, `docmostJwtIssuer` default "Docmost", `docmostJwtAudience`) + helper `isDocmostJwtEnabled()`
|
||||
- `bridge/src/lib/container.ts` : champ `docmostJwt: DocmostJwtVerifier | null`, init si secret >= 32 chars
|
||||
- `bridge/src/middleware/auth.ts` : routing par algo JWT (decode header non verifie -> RSA -> OIDC, HMAC -> DocAdenice). Sources d'auth ajoutees : `docmost-jwt`, `docmost-cookie`. Refactor en helpers internes pour separer la logique attach par mode.
|
||||
- `bridge/src/index.ts` : injecte `ctn.docmostJwt` dans l'app
|
||||
- `bridge/.env.example` : section commentee `DOCMOST_APP_SECRET` / `DOCMOST_JWT_ISSUER` / `DOCMOST_JWT_AUDIENCE`
|
||||
- `bridge/vitest.config.ts` : threshold >= 85% sur `docmost-jwt-verifier.ts`
|
||||
- `bridge/tests/middleware/auth.test.ts` : +14 tests (DocAdenice mode + coexistence Authentik/DocAdenice + algo none rejected)
|
||||
|
||||
**Quality gates** :
|
||||
- typecheck : OK
|
||||
- lint : OK
|
||||
- tests : 292/292 verts (was 250/250 — +42 tests)
|
||||
- coverage : `docmost-jwt-verifier.ts` 100% lines/funcs/97.87% branches, `auth.ts` 96.35% lines/93.61% branches
|
||||
|
||||
**Choix techniques** :
|
||||
- `decodeJwtAlg` : decode header non verifie pour router vers le bon mode. Si JWT non decodable -> AUTH_INVALID immediat. Si algo n'a pas de mode actif -> AUTH_INVALID (pas de fallback silencieux).
|
||||
- DocAdenice JWT : pas de mapping `groupsScopesMap` — le claim `acadenice_permissions[]` (R2.1) est la source de verite directe (DocAdenice resout deja tout via son RBAC). `scopes = permissions = acadenice_permissions[]`.
|
||||
- Constant-time : `jose.jwtVerify` utilise `node:crypto.timingSafeEqual` pour comparaison HMAC.
|
||||
- Algo none / ES* / EdDSA explicitement rejetes (`AUTH_INVALID`) — seuls RS* (mode 2) et HS* (mode 3) routent quelque part.
|
||||
- Validation claims requis dans le verifier : `sub`, `workspaceId`, `type` doivent etre presents et non vides.
|
||||
|
||||
---
|
||||
|
||||
# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 R1 refactor)
|
||||
|
||||
## Vision — DocAdenice = Notion-like generique
|
||||
|
||||
**Pivot strategique 2026-05-07** : DocAdenice n'est plus un outil metier
|
||||
formation-hub mais un **produit Notion-like generique**. Le bridge est livre
|
||||
vide a un user (admin), qui cree ses tables Baserow comme il veut via UI ou
|
||||
API. Le code n'a aucune ontologie metier.
|
||||
|
||||
Composants cibles :
|
||||
- **Pages / Spaces** : Docmost reskin (DocAdenice fork) + spaces hierarchiques
|
||||
- **Databases custom** : tables Baserow exposees via le bridge `/api/v1/tables/*`
|
||||
- **RBAC dynamique** : roles custom declares cote DocAdenice qui projettent
|
||||
dans le JWT le claim `acadenice_permissions[]`
|
||||
- **Bidirec backlinks** : pages <-> rows + mentions cross-page (R3)
|
||||
- **Slash commands custom** : Tiptap node-views (R3)
|
||||
- **Dual editor** : edition wiki Docmost + edition table Baserow inline (R3)
|
||||
|
||||
Le metier formation-hub (CFA + Agence Acadenice) devient un **exemple parmi
|
||||
d'autres** : `examples/acadenice-formation-hub/`.
|
||||
|
||||
## CHANGELOG depuis derniere update (R1 — refactor proxy generique style Notion)
|
||||
|
||||
- **R1 livre (suppression domain metier + bridge generique)** :
|
||||
- Supprime tout le metier formation-hub du bridge :
|
||||
- 9 entites domain (Personne, Formation, Bloc, Module, Attribution, Client, Projet, Tache, Intervention)
|
||||
- types.ts (Role, StatutPersonne, etc.)
|
||||
- baserow-repo.ts (le mega-fichier 554 LOC avec 9 repos heritant de BaseRepo)
|
||||
- 6 routes metier (personnes, formations, projets, modules, interventions, attributions)
|
||||
- Tous les tests metier correspondants (10 tests domain + 6 tests routes + 1 test repo + 1 test rate-limit-app metier)
|
||||
- Cree le domain generique :
|
||||
- `domain/table.ts` (Table : id, name, databaseId, fields[], orderIndex)
|
||||
- `domain/row.ts` (Row : id, tableId, fields opaque, createdOn, updatedOn, order)
|
||||
- `domain/field.ts` (Field : id, name, type libre, primary, options nullable)
|
||||
- `domain/view.ts` (View : id, name, type, tableId)
|
||||
- `domain/schemas.ts` refonte zod (TableSchema, RowSchema, FieldSchema, ViewSchema, RowFieldsSchema permissif)
|
||||
- Cree les repos generiques :
|
||||
- `repos/baserow-tables-repo.ts` (list/get tables — JWT requis upstream)
|
||||
- `repos/baserow-rows-repo.ts` (CRUD rows par tableId — DB token OK)
|
||||
- `repos/baserow-fields-repo.ts` (list fields par tableId — DB token OK)
|
||||
- `repos/baserow-views-repo.ts` (list views + runGrid — DB token OK)
|
||||
- Cree la route generique unique :
|
||||
- `routes/tables.ts` avec 9 endpoints REST :
|
||||
`GET /tables`, `GET /tables/:id` (+ fields embarques),
|
||||
`GET /tables/:id/fields`, `GET /tables/:id/views`,
|
||||
`GET /tables/:id/views/:viewId/rows`,
|
||||
`GET /tables/:id/rows`, `GET /tables/:id/rows/:rowId`,
|
||||
`POST /tables/:id/rows`, `PATCH /tables/:id/rows/:rowId`,
|
||||
`DELETE /tables/:id/rows/:rowId`
|
||||
- Scopes generiques : `read:tables`, `write:tables`, `admin:*`
|
||||
- 501 NOT_IMPLEMENTED si DB token sur endpoint qui exige JWT (list/get tables metadata)
|
||||
- Etendu `BaserowClient` : `listTables`, `getTable`, `listFields`, `listViews`, `getGridViewRows`
|
||||
- Refactor `middleware/auth.ts` :
|
||||
- Supprime entierement le lookup `personneRepo.findByEmail` + cache Personne par email
|
||||
- Supprime `strictMapping` (plus de notion d'email orphelin)
|
||||
- Lit le claim JWT `acadenice_permissions[]` directement dans `extractPermissions(payload)`
|
||||
- `AuthenticatedUser.scopes` = union (groups -> scopes) + (permissions claim)
|
||||
- Plus de `roles[]` dans `AuthenticatedUser` — remplace par `permissions[]`
|
||||
- Refactor `middleware/scopes.ts` :
|
||||
- Supprime `DEFAULT_ROLE_SCOPES` (plus de mapping role formation-hub)
|
||||
- `computeOidcScopes(groups, permissions, groupsMap)` — la signature change
|
||||
- Refactor `webhooks/baserow-handler.ts` :
|
||||
- Plus de cascade rollup metier (attribution -> module + personne, etc.)
|
||||
- Pour chaque event Baserow sur `tableX` : invalide uniquement
|
||||
`bridge:tables:<tableX>:list:*`, `bridge:tables:<tableX>:views:*`,
|
||||
`bridge:tables:<tableX>:row:<id>` (si update/delete)
|
||||
- Si l'utilisateur veut des cascades cross-table, il les pose en formules/lookups Baserow
|
||||
qui emettent leurs propres webhooks naturellement
|
||||
- Refactor `lib/cache.ts` :
|
||||
- `invalidateEntity(redis, entity, id?)` -> `invalidateTable(redis, tableId, rowId?)`
|
||||
- Patterns : `bridge:tables:<tableId>:*` (plus de pattern par entite metier)
|
||||
- Refactor container :
|
||||
- Supprime `tableIds` field (plus de mapping name->id metier)
|
||||
- `RepoSet` = `{ tables, rows, fields, views }` (4 repos generiques)
|
||||
- Supprime `pickTableIds` + `resolveTableIds` au boot (plus necessaire)
|
||||
- Refactor config :
|
||||
- Supprime `authStrictMapping` (plus de Personne lookup)
|
||||
- `BASEROW_TABLE_IDS` env retire (plus de mapping metier)
|
||||
- `.env.example` reecrit : scopes generiques, plus de mention formation-hub
|
||||
- Sortie metier vers exemples : cree `examples/acadenice-formation-hub/`
|
||||
avec README.md, seed-baserow.md (schema 9 tables markdown), example-roles.md
|
||||
(Formateur, Developpeur, Direction, Support, Admin avec permissions generiques)
|
||||
- Tests : 250/250 verts (depuis 319/319). 33 tests metier supprimes ; 33 tests
|
||||
generiques ajoutes (4 domain : table/row/field/view, 4 repos generiques,
|
||||
19 routes /tables, edge cases, errors helpers, http helpers, isOidcEnabled).
|
||||
- Coverage globale : 89.54% lines / 92.42% branches.
|
||||
- domain/** : 98.9% lines / 93.75% branches (>= 80% ✓)
|
||||
- adapters/** : 89.04% lines / 95.04% branches (>= 70% ✓)
|
||||
- middleware/auth.ts : 97.08% lines / 92% branches (>= 85% ✓)
|
||||
- middleware/rate-limit.ts : 100% (>= 85% ✓)
|
||||
- lib/cache.ts : 100% (>= 85% ✓)
|
||||
- webhooks/** : 100% (>= 80% ✓)
|
||||
- Quality gates verts : `typecheck`, `lint`, `test`, `test:coverage`.
|
||||
|
||||
## Status R1/R2/R3
|
||||
|
||||
| Bloc | Status | Detail |
|
||||
|------|--------|--------|
|
||||
| **R1 — Bridge refactor proxy generique style Notion** | DONE | Suppression domain metier + nouvelles routes /api/v1/tables/* |
|
||||
| R2 — RBAC dynamique cote DocAdenice (claim `acadenice_permissions[]`) | TODO | docmost-fork-dev |
|
||||
| R3 — Bidirec backlinks + slash commands + dual editor | TODO | Phase 3 |
|
||||
|
||||
## CHANGELOG anterieur (Bloc 5 — rate limit + cache invalidation cote writes)
|
||||
|
||||
- **Bloc 5 livre (rate limit defensif + invalidation cache writes)** :
|
||||
- Nouveau module `src/middleware/rate-limit.ts` : middleware Hono autour de `RedisCache.checkRateLimit` (sliding window deja teste integration). Cle derivee de l'identite avec priorites : `tokenId` (service token) > `email` OIDC (lower-cased) > `sub` OIDC > IP via `x-forwarded-for` (avec WARN log car spoofable) > `anonymous`. Throw `errors.rateLimited(windowSeconds)` avec headers `X-RateLimit-Limit/Remaining/Reset`. Helper exporte `defaultRateLimitKey` pour composer (`${default}:mut`).
|
||||
- Nouveau module `src/lib/cache.ts` : `invalidateEntity(redis, entity, id?)` qui mirror la logique cascade de `webhooks/baserow-handler.ts` (attribution -> module + personne, intervention -> tache + personne, etc.). Volontairement duplique plutot qu'extrait commun car les contextes sont differents (event_type webhook vs intent route).
|
||||
- Wire `src/index.ts` :
|
||||
- `rateLimit(redis, {global})` sur `/api/v1/*` apres l'auth middleware.
|
||||
- `rateLimit(redis, {mutation, keyFrom: ...:mut})` ajoute conditionnellement sur POST/PATCH/PUT/DELETE — compteur Redis distinct, plus strict.
|
||||
- **Pas de rate limit** sur `/api/health`, `/api/ready`, `/api/webhooks/*` (ces dernieres ont HMAC + idempotence Redis qui couvrent).
|
||||
- Routes mutation appellent `invalidateEntity()` apres write reussi (3 routes : `POST /modules/:id/attribuer`, `POST /interventions`, `PATCH /attributions/:id/heures-realisees`). Ferme la fenetre stale entre l'ecriture Baserow et l'arrivee du webhook (idempotent avec l'invalidation webhook qui suivra).
|
||||
- Config zod : 4 vars ajoutees avec defauts `100/60s` global et `30/60s` mutation. Toutes coercees + optionnelles via env (`RATE_LIMIT_GLOBAL_MAX`, etc.).
|
||||
- `.env.example` : section rate limit reecrite (commentee, defauts documentes), ancienne triple-var Phase 1 supprimee.
|
||||
- Tests : **29 tests ajoutes** (290 -> 319). 11 tests rate-limit middleware (cles, priorites, 429, headers, mutation independance, anonymous fallback), 11 tests cache helper (cascade par entite, idempotence, total returned), 7 tests integration `/api/v1/*` vs health/webhooks. Pas de fake timers : `RedisCache.checkRateLimit` est mocke, on simule la reset par mutation du compteur fake (equivalent fonctionnel).
|
||||
- Coverage : `src/middleware/rate-limit.ts` = **100% lines / 100% branches / 100% funcs**. `src/lib/cache.ts` = **100% lines / 100% branches / 100% funcs**. Coverage globale 87.7% (+0.3pt).
|
||||
- vitest.config.ts thresholds : ajout `src/middleware/rate-limit.ts` et `src/lib/cache.ts` a 85%.
|
||||
|
||||
# SESSION RESUME — formation-hub Acadenice (Bloc 4 — auth OIDC-ready)
|
||||
|
||||
## CHANGELOG (Bloc 4 — auth OIDC-ready)
|
||||
|
||||
- **Bloc 4 livre (middleware OIDC-ready, dual mode)** :
|
||||
- Nouveau module `src/middleware/oidc-verifier.ts` : verification JWT via `jose` + JWKS remote (cache 10min). Algorithmes acceptes : RS256/RS384/RS512 (pas HS* puisque cle publique). Throw `errors.authInvalid()` sur tout echec (signature, expired, issuer mismatch, audience mismatch).
|
||||
- Nouveau module `src/middleware/scopes.ts` : `parseGroupsScopesMap` (parse JSON env var) + `computeOidcScopes` (union groups Authentik + roles formation-hub). Defaut role-scope conservateur (admin -> `admin:*`, formateur -> `read:personnes,read:formations,write:attributions`, etc.).
|
||||
- Refactor `src/middleware/auth.ts` (mais service tokens 100% retro-compat) :
|
||||
- Schemes acceptes : `Authorization: ApiKey brg_*`, `Authorization: Bearer brg_*` (service token) OU `Authorization: Bearer <jwt>` OU cookie `authToken=<jwt>` (OIDC).
|
||||
- Si OIDC desactive (vars Authentik manquantes) + JWT envoye -> 401 (pas de fallback silencieux).
|
||||
- Lookup `PersonneRepo.findByEmail` (nouvelle methode) + cache Redis 60s avec key `bridge:auth:personne-by-email:<sha256(email)>` (RGPD : pas d'email en clair dans Redis). Cache positif et negatif.
|
||||
- Type `AuthenticatedUser` injecte dans Hono context : `{ source, tokenId?, email?, sub?, personneId?, roles, groups, scopes }`.
|
||||
- Mode strict (defaut) : email orphelin (JWT valide mais pas de Personne) -> 403 FORBIDDEN. Mode permissif : autorise avec scopes des groups uniquement.
|
||||
- `requireScope` etendu : wildcard prefix (`read:*` couvre `read:personnes`, etc.) + admin:*.
|
||||
- Config zod (`src/lib/config.ts`) : ajout `authentikIssuer`, `authentikJwksUri`, `authentikAudience`, `authGroupsScopesMap`, `authStrictMapping` (toutes optionnelles). Helper `isOidcEnabled()` retourne true ssi 3 vars Authentik set.
|
||||
- `PersonneRepo.findByEmail(email)` : recherche via `search` Baserow puis filtre exact post-fetch (case-insensitive + trim). Retourne null sur miss/row-malformee (vs throw).
|
||||
- Erreurs : ajout code `FORBIDDEN` (vs `FORBIDDEN_SCOPE` deja existant) pour les cas auth-mais-pas-de-droits-metier.
|
||||
- Container DI : ajout `oidc: OidcVerifier | null` + `groupsScopesMap`. Construit au boot si vars set.
|
||||
- Tests : **30 tests ajoutes** (260 -> 290). 27 tests integration auth middleware (12 cas reglementaires + 15 cas annexes), 10 tests scopes mapping, 5 tests `findByEmail`. Mini serveur HTTP local genere une cle RSA via `jose.generateKeyPair` -> sert un JWKS reel -> verifier le tape via fetch (plus realiste que mocker `createRemoteJWKSet`).
|
||||
- Coverage `src/middleware/auth.ts` = **94.11% lines / 88.37% branches** (seuil >= 85% applique dans `vitest.config.ts`). Coverage globale stable a 87.38%.
|
||||
- `.env.example` enrichi (commente, prefixe `# AUTHENTIK_*`). `.env` local inchange — Authentik n'est pas branche en local pour l'instant, le mode reste service-tokens-only par defaut.
|
||||
- Lib ajoutee : `jose@^6.2.3` (standard moderne OIDC, ESM-first).
|
||||
|
||||
# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 7)
|
||||
|
||||
## CHANGELOG depuis derniere update (Bloc 7 — webhooks)
|
||||
|
||||
- **Bloc 7a livre (Baserow webhooks complet)** + **Bloc 7b stub (Docmost)** :
|
||||
- Nouveau module `src/webhooks/` : `signature.ts` (HMAC-SHA256 hex constant-time + format `sha256=` accepte), `types.ts` (zod payloads Baserow + Docmost), `baserow-handler.ts` (mapping table_id -> entite + invalidation cache + cascade rollups parents), `docmost-handler.ts` (stub log-only avec TODO Bloc 8).
|
||||
- Route `POST /api/webhooks/baserow` (HMAC `X-Baserow-Signature`, idempotence Redis 24h, dispatch invalidation par event_type, table inconnue -> 200 ignored).
|
||||
- Route `POST /api/webhooks/docmost` stub (HMAC `X-Docmost-Signature`, idempotence si event_id present, log + 200).
|
||||
- Body brut via `c.req.text()` puis `JSON.parse` manuel (stream consomme une seule fois).
|
||||
- Config zod : `docmostWebhookSecret` ajoute (optional, min 16 chars).
|
||||
- 40 tests Vitest ajoutes (220 -> 260) : signature 13 / handler baserow 10 / handler docmost 2 / routes 15.
|
||||
- Coverage `src/webhooks/**` = **100% lines/branches/funcs**, `src/routes/webhooks.ts` = **97.77% lines / 96.42% branches**.
|
||||
- vitest.config.ts thresholds : ajout `src/webhooks/**` a 80%.
|
||||
|
||||
# SESSION RESUME — formation-hub Acadenice (Bloc 6, conserve pour reference)
|
||||
|
||||
> Document de reference pour reprendre le travail apres restart Claude Code OU /compact.
|
||||
> Lis-moi avant de commencer la prochaine session.
|
||||
|
||||
## CHANGELOG depuis derniere update (session 2026-05-07 nuit — Bloc 6)
|
||||
|
||||
5 commits ajoutes (`5b2abbc`, `2c5665b`, `c8e9b4d`, `7a3fbe4`, `1528017`) — bridge passe de "scaffold + 4 agents recrutes" a "service utilisable end-to-end + adapters couverts a 97-100%" :
|
||||
|
||||
- **Bloc 1 cloture** (`5b2abbc`) : adapters propres (TS errors fixed, biome format), 679 LOC.
|
||||
- **Bloc 2 livre** (`2c5665b`) : domain models 12 fichiers (Personne, Module, Attribution, Tache, etc.) + 111 tests Vitest, coverage **97.86%** lines sur `src/domain/`. Decimal.js partout pour heures, schemas zod, RG-01 implementee dans Module.creerAttribution.
|
||||
- **Bloc 3 livre** (`c8e9b4d`) : routes REST Tier 1 + auth middleware + repos Baserow + tests integration mockes. **10/10 endpoints livres** : GET personnes/:id/dashboard, GET formations/:id, GET projets/:id, POST modules/:id/attribuer, POST interventions, PATCH attributions/:id/heures-realisees, etc. Tests : **163/163 verts**, coverage globale `src/` : **70.77%**.
|
||||
- **Smoke test fixes** (`7a3fbe4`) : 2 bugs decouverts via test live contre Baserow + Docmost reels :
|
||||
- `BaserowClient.resolveTableIds` requiert un JWT user (Baserow API distingue DB tokens / JWT). Workaround : env var `BASEROW_TABLE_IDS` JSON override.
|
||||
- `BaseRepo.list` cassait sur row malformee (Personne avec splits null != 100 → throw). Fix : try/catch toDomain par row, skip + log warn + `meta.skipped` exposed.
|
||||
- **Bloc 6 livre** (`1528017`) : tests integration des 3 adapters via bridge-tester. **59 nouveaux tests** (220/220 verts au total) :
|
||||
- `redis-cache.test.ts` : 16 tests via testcontainers redis:7-alpine, **100% lines / 95.2% branches**.
|
||||
- `baserow-client.test.ts` : 18 tests via faux serveur node:http local, **99% lines / 96.9% branches**.
|
||||
- `docmost-client.test.ts` : 25 tests via faux serveur node:http (login + cookie + envelope `{data}`), **97.7% lines / 93.7% branches**.
|
||||
- Choix technique : faux serveur HTTP plutot que container Baserow/Docmost (boot 60-120s incompatible CI rapide). Le code adapter tape un vrai socket TCP via ofetch/fetch — boundary integration rigoureux. Helper reutilisable `tests/helpers/http-server.ts`.
|
||||
- vitest.config.ts : threshold 70% lines+branches ajoute sur `src/adapters/**`.
|
||||
- Note design : `RedisCache.checkRateLimit` utilise `${Date.now()}` comme membre ZSET → collision si plusieurs appels dans la meme ms. Workaround dans tests (delay 2ms). Pas critique en prod (charge plus diffuse) mais a noter.
|
||||
|
||||
## Smoke test live — etat actuel
|
||||
|
||||
Stack live + bridge testes :
|
||||
- Baserow : `http://localhost:8080` (workspace 112 "Acadenice", database 133 "formation-hub", 9 tables au singulier 609-617)
|
||||
- Docmost : `http://localhost:3000`
|
||||
- Redis bridge dedie : container `bridge-redis` sur `127.0.0.1:6379` (separe du `docmost-redis` interne)
|
||||
- Bridge : `http://localhost:4000` via `npm run dev` dans `bridge/`
|
||||
|
||||
`.env` du bridge cree (gitignore confirme). Token Baserow DB cree : `vyabYuYW7E5BLTTV7RGbl2Y0Mkk4hvHP` (workspace 112, CRUD complet). Token bridge admin de test : `brg_smoketest_admin` avec scope `admin:*`.
|
||||
|
||||
7/8 endpoints OK au smoke test (le 8e bug est fix dans `7a3fbe4`). Tableau detaille :
|
||||
|
||||
| Endpoint | Resultat |
|
||||
|----------|----------|
|
||||
| GET /api/health | 200 |
|
||||
| GET /api/ready | 200 (baserow:true, redis:true) |
|
||||
| GET /personnes (no auth) | 401 AUTH_REQUIRED |
|
||||
| GET /personnes (bad token) | 401 AUTH_INVALID |
|
||||
| GET /personnes (good, 2 rows malformees) | 200 data:[] meta.skipped:2 (apres fix) |
|
||||
| GET /personnes/9999 | 404 NOT_FOUND |
|
||||
| GET /formations | 200 (2 rows) |
|
||||
| GET /projets | 200 (2 rows) |
|
||||
|
||||
## Etat des blocs Phase 2 (a jour)
|
||||
|
||||
| Bloc | Status | Detail |
|
||||
|------|--------|--------|
|
||||
| 1 — Adapters | DONE | `5b2abbc`, coverage adapters 97-100% via Bloc 6 |
|
||||
| 2 — Domain models | DONE | `2c5665b`, 97.86% coverage |
|
||||
| 3 — Routes Tier 1 + auth + repos | DONE | `c8e9b4d`, 10/10 endpoints, 86-96% coverage middleware/routes |
|
||||
| 3.2 — Refactor erreurs domain typees + routes /blocs /clients /taches | TODO | DomainError sub-classes (RGViolationError, ConflictError) pour remplacer mapping par texte |
|
||||
| 4 — Auth middleware OIDC-ready | DONE | dual mode service-token + Authentik JWT/cookie, mode OIDC desactive en local (vars env absentes), 27 tests coverage 94.11% |
|
||||
| 4b — Docmost OIDC fork (rebrand DocAdenice + login Authentik) | TODO | docmost-fork-dev — depend Bloc 4 |
|
||||
| 5 — Rate limit + cache invalidation | DONE | middleware/rate-limit.ts (100%), lib/cache.ts (100%), wire global + mutation sur /api/v1/*, invalidation 3 routes write |
|
||||
| 6 — Tests integration adapters | DONE | `1528017`, 59 tests, redis-cache 100% / baserow 99% / docmost 97.7% lines |
|
||||
| 7a — Webhook Baserow (HMAC + idempotence + invalidation cache) | DONE | webhooks/* + routes/webhooks.ts, 100% coverage |
|
||||
| 7b — Webhook Docmost (stub) | STUB | log-only, handlers metier en Bloc 8 |
|
||||
| 7 — Sync bidirec (write-back Baserow apres event Docmost) | TODO | depend de Bloc 8 (parser node-views) |
|
||||
| 8 — Tiptap node-views Docmost | TODO | docmost-fork-dev, Phase 2.3+ — PROCHAIN |
|
||||
| 9 — Bidirec backlinks | TODO | docmost-fork-dev, Phase 3 |
|
||||
| 10 — Doc utilisateur + release v0.1.0 | TODO | tech-writer + acadenice-devops |
|
||||
|
||||
## Coverage globale (post-Bloc 7)
|
||||
|
||||
- **All files** : 87.38% lines / 86.31% branches (post-Bloc 4)
|
||||
- **adapters/** : 98.73% lines / 95.04% branches
|
||||
- **domain/** : 97.86% lines / 98.16% branches
|
||||
- **routes/** : 96.58% lines / 76.19% branches (incluant webhooks.ts 97.77%)
|
||||
- **webhooks/** : **100% lines / 100% branches** (signature, baserow-handler, docmost-handler, types)
|
||||
- **middleware/** : 92.12% lines / 85.36% branches (auth.ts 94.11/88.37, oidc-verifier.ts 88.88/58.33, scopes.ts 100/95.45)
|
||||
- **lib/** : 49.18% lines (config.ts/container.ts non couverts — bootstrap)
|
||||
- **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets)
|
||||
|
||||
## Vote pour la prochaine session
|
||||
|
||||
Recommandation pour la reprise :
|
||||
|
||||
- **Option A** : DocAdenice rebrand + Bloc 4b — fork Docmost pour ajouter login Authentik + theme Acadenice. Le bridge cote serveur est pret (Bloc 4 livre), reste a brancher Authentik live + faire en sorte que Docmost emette le cookie `authToken` ou un Bearer JWT vers le bridge.
|
||||
- **Option B (recommandee si pas d'Authentik live)** : Bloc 8 — Tiptap node-views Docmost (docmost-fork-dev). Forke Docmost AGPL, ajoute les nodes custom `baserow-row` / `baserow-list` qui font des reads via le bridge `/api/v1/*` et des writes via webhooks Docmost (handler stub deja en place — il restera a parser le payload reel et appeler les repos Baserow). Cest la piece UI manquante qui rend le bridge visible cote utilisateur.
|
||||
- **Option B** : Bloc 9 — tests E2E Playwright contre la stack live (bridge-tester) pour figer le comportement actuel des routes + webhooks avant que le fork Docmost ne bouge.
|
||||
- **Option C** : Bloc 5 — rate limit + cache invalidation middleware. Court (~1h). RedisCache.checkRateLimit existe deja, faut le wire dans Hono. Pas bloquant pour Bloc 8.
|
||||
- **Option D** : Bloc 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent.
|
||||
|
||||
## Vision projet en 3 lignes
|
||||
|
||||
Notion-like self-host pour Acadenice (CFA + Agence dev) en Stack composite :
|
||||
- **Docmost** (wiki AGPL, illimite users) + **Baserow** (DBs MIT, illimite users) + **bridge service** custom Node TS (Phase 2)
|
||||
- Suivi heures formateurs/devs unifie via entite **PERSONNE** pivot multi-roles, scope etendu CFA + Agence approuve.
|
||||
- Cible 90-100 users total. Production-like des le jour 1.
|
||||
|
||||
## User & equipe
|
||||
|
||||
- **Corentin JOGUET** (corentin@acadenice.fr) — AdminSys/DevOps solo, bras droit de Yan (resp tech). Decisionnaire technique.
|
||||
- Yan (resp tech), Ludo (fondateur), Sophie (co-fondatrice) — validation business. Pas a confondre avec Corentin.
|
||||
|
||||
## Localisation des artefacts
|
||||
|
||||
| Resource | Chemin / URL |
|
||||
|----------|--------------|
|
||||
| Repo source of truth | https://git.acadenice.com/AcadeNice/Wiki (Forgejo selfhost, public) |
|
||||
| Repo mirror GitHub | https://github.com/AcadeNice/wiki (private, plan free) |
|
||||
| Local dev | `/home/imugiii/Documents/jsap/formation-hub/` |
|
||||
| Wiki conception (19 docs) | https://wiki.acadenice.com/collection/agence-rd-notion-like-v9nvBLodst |
|
||||
| BYAN web project | id `4e72108b-dc05-4938-a1a9-530e1551ed52` |
|
||||
| Stack locale | http://localhost:3000 (Docmost) + http://localhost:8080 (Baserow) |
|
||||
|
||||
## Phase 0 — Conception (DONE — 19 docs)
|
||||
|
||||
Localises dans `docs/` du repo + miroir Outline collection R&D Notion-Like :
|
||||
|
||||
| # | Doc | Status |
|
||||
|---|-----|--------|
|
||||
| 01 | Discovery Recap | OK |
|
||||
| 02 | Scope etendu CFA + Agence (APPROVED 2026-05-07) | OK |
|
||||
| 03 | Decision Records (5 ADR) | OK |
|
||||
| 04 | CDC Technique (stack + NFR + roadmap + couts) | OK |
|
||||
| 05 | Data Dictionary | OK |
|
||||
| 06 | Merise MCD (5 vues splittees) | OK |
|
||||
| 07 | Merise MLD (5 vues splittees) | OK |
|
||||
| 08 | Merise MCT | OK |
|
||||
| 09 | Merise MOT | OK |
|
||||
| 10 | State Diagrams | OK |
|
||||
| 11 | UML Use Cases (4 vues splittees) | OK |
|
||||
| 12 | UML Class Diagram (5 vues splittees) | OK |
|
||||
| 13 | UML Activity Diagrams | OK |
|
||||
| 14 | Repo Structure & GitOps | OK |
|
||||
| 15 | Baserow MPD | OK |
|
||||
| 16 | Plan de tests | OK |
|
||||
| 17 | Plan de deployment | OK |
|
||||
| 18 | Plan d'operations | OK |
|
||||
| 19 | Bridge API design (incl. MCP server Phase 3+) | OK |
|
||||
| 99 | DRAWIO Architecture infra (XML) | OK |
|
||||
|
||||
## Phase 1 — Build local (en cours — local seul, prod-like)
|
||||
|
||||
### OK et teste live
|
||||
|
||||
| Iteration | Detail |
|
||||
|-----------|--------|
|
||||
| **I1 — Baserow tables + liens** | 9 tables (PERSONNE + CFA + Agence) + 10 link FK avec related fields renommes |
|
||||
| **I2 — Baserow formulas** | 17 formulas (rollups + heures_restantes) |
|
||||
| **I3 — Docmost setup** | Workspace `Acadenice` + 3 spaces (CFA, Agence, Interne) + page Welcome + share link |
|
||||
| **I5a — Healthcheck etendu** | UI + API Docmost/Baserow + container status — 4/4 OK |
|
||||
|
||||
### Partiellement OK
|
||||
|
||||
| Item | Probleme | Fix prevu |
|
||||
|------|----------|-----------|
|
||||
| **I4a — Forms publics Baserow** | Form cree mais endpoint `/api/database/views/form/{id}/field-options/` retourne 404 sur Baserow 1.30 | A investiguer (URL exacte selon version) — bridge-dev |
|
||||
| **I4b — Space etudiant Docmost** | Slug fix applique (`re.sub`), limit fix (200→100). Pas re-teste. | Re-run pour confirmer — quick |
|
||||
| **I5b — Cron install** | Script ecrit non-execute (sudo requis) | A run sur la prod quand VPS sera up — acadenice-devops |
|
||||
| **I5c — Backup test E2E** | Script `scripts/backup.sh` existant, pas teste end-to-end avec restore | Test mensuel selon plan ops — acadenice-devops |
|
||||
|
||||
### TODO Phase 1 finale
|
||||
|
||||
| Item | Pour qui |
|
||||
|------|----------|
|
||||
| Test rollups Baserow live (1 personne + 1 formation + 1 attribution) | Corentin |
|
||||
| Migration data initiale (formations/clients existants) | Corentin + Yan + Sophie |
|
||||
| Onboarding 5-10 testeurs (Yan, Ludo, Sophie + 2-3 formateurs + 2 devs) | Corentin |
|
||||
| Setup VPS staging (Hetzner CPX21) | acadenice-devops |
|
||||
| Configurer Forgejo Actions runner (`infra/forgejo-runner/`) | acadenice-devops |
|
||||
|
||||
## Phase 2 — Bridge service (en cours, Blocs 1-3 + fix smoke test livres)
|
||||
|
||||
Code Phase 2 = MAIN focus de la session 2026-05-07 soir + suite. Brief complet dans `docs/19-bridge-api-design.md`. Architecture :
|
||||
- 5 missions : expose Baserow, webhooks Baserow, sert Tiptap nodes Docmost, orchestre workflows metier, **sync bidirec Docmost ↔ Baserow**
|
||||
- Stack fixee : Node 22 + Hono + zod + ofetch + ioredis + pino + decimal.js + Vitest + Biome
|
||||
- Endpoints REST `/api/v1/*` versionnes (10 livres Tier 1)
|
||||
- Webhooks anti-loop via header `X-Bridge-Origin`
|
||||
- MCP server (Phase 3+) co-located dans le meme service
|
||||
|
||||
Cf section "Etat des blocs Phase 2 (a jour)" en haut du document pour le status detaille de chaque bloc.
|
||||
|
||||
## Agents BYAN crees pour le projet
|
||||
|
||||
Dans `.claude/agents/` du repo :
|
||||
|
||||
| Agent | Mission | Quand l'invoquer |
|
||||
|-------|---------|------------------|
|
||||
| **bridge-dev** | Code TS du bridge service (adapters, domain, routes, webhooks) | Toute tache code metier dans `bridge/` |
|
||||
| **bridge-tester** | Tests Vitest + testcontainers + E2E Playwright + coverage | Toute tache test bridge ou validation AC |
|
||||
| **acadenice-devops** | Infra (Docker, Traefik, Forgejo, backups, monitoring, CI/CD) | Toute tache ops/deploy/infra |
|
||||
| **docmost-fork-dev** | Fork Docmost + Tiptap node-views React + bidirec backlinks | Phase 2.3+ et Phase 3 (UI custom) |
|
||||
|
||||
**Invocation** : Agent tool avec `subagent_type='bridge-dev'` (ou autre nom) apres restart Claude Code.
|
||||
|
||||
Chaque agent a un brief detaille (~150-200 lignes) avec :
|
||||
- Mission + contexte projet
|
||||
- Stack technique fixee
|
||||
- Specialisations
|
||||
- Conventions code & commits
|
||||
- Limites (ce qu'il ne fait PAS)
|
||||
- Resources & references
|
||||
|
||||
## Workflows BYAN proposes (a creer plus tard)
|
||||
|
||||
Pas encore crees. A faire via BYAN web ou skill `byan-bmb-workflow-builder` :
|
||||
|
||||
| Workflow | Phases |
|
||||
|----------|--------|
|
||||
| **WF formation-hub BUILD** | story → bridge-dev code → bridge-tester tests → user review → push → deploy staging → smoke tests. Boucle si fail. |
|
||||
| **WF formation-hub SYNC** | webhook Baserow → bridge handler → cache invalidation → notif → log audit. Idempotence event_id. |
|
||||
| **WF formation-hub RELEASE** | tests E2E staging → CHANGELOG update → tag semver → approval review → deploy prod → 30 min watch period → rollback si fail. |
|
||||
|
||||
## Decisions structurelles (a respecter)
|
||||
|
||||
| Decision | Reference |
|
||||
|----------|-----------|
|
||||
| Stack Docmost + Baserow + bridge custom | ADR-001 doc 03 |
|
||||
| Path B : UX quasi-unified via Tiptap nodes | ADR-002 doc 03 |
|
||||
| Monorepo trunk-based development | ADR-003 doc 03 |
|
||||
| Postgres separe par service | ADR-004 doc 03 |
|
||||
| Bridge stack Node 22 + Hono | ADR-005 doc 03 |
|
||||
| Scope etendu CFA + Agence via PERSONNE pivot | ADR-006 doc 02 |
|
||||
| Etudiants pas modelises en Baserow, juste users Docmost | doc 02 |
|
||||
| API Docmost = Enterprise paye → on utilise endpoints internes (AGPL legal) | doc 19 |
|
||||
| Repo source of truth = Forgejo selfhost (git.acadenice.com), GitHub mirror optionnel | doc 14 |
|
||||
| **Pas de mirror auto** decide pour l'instant | session 2026-05-07 |
|
||||
| **Local seul** pour le moment (pas de staging deploy) | session 2026-05-07 |
|
||||
| Pas de modification des docs conception sans ADR | session 2026-05-07 |
|
||||
|
||||
## Credentials utilises (dans `.env` gitignore — a regenerer si compromis)
|
||||
|
||||
Racine `.env` :
|
||||
```
|
||||
DOCMOST_ADMIN_EMAIL=corentin@acadenice.fr
|
||||
DOCMOST_ADMIN_PASSWORD=ton-pwd123456
|
||||
BASEROW_EMAIL=admin@acadenice.fr
|
||||
BASEROW_PASSWORD=ton-pwd123456
|
||||
GITHUB_TOKEN=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 (a revoke apres session)
|
||||
FORGEJO_TOKEN=cc21fee2913b6043fb68f93d8b6c184fac4671f4 (admin AcadeNice)
|
||||
OUTLINE_TOKEN=ol_api_s2EqjDW5SPlXzM4vqiZaMd8UD00jsnespK4rRs
|
||||
```
|
||||
|
||||
`bridge/.env` (cree session 2026-05-07 soir) :
|
||||
```
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
LOG_LEVEL=debug
|
||||
BASEROW_API_URL=http://localhost:8080
|
||||
BASEROW_API_TOKEN=vyabYuYW7E5BLTTV7RGbl2Y0Mkk4hvHP
|
||||
DOCMOST_API_URL=http://localhost:3000
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
BASEROW_WEBHOOK_SECRET=smoke-test-webhook-secret-32chars-min
|
||||
BRIDGE_API_TOKENS=[{"token":"brg_smoketest_admin","name":"smoketest","scopes":["admin:*"]}]
|
||||
BASEROW_DATABASE_ID=133
|
||||
BASEROW_TABLE_IDS={"personne":609,"formation":610,"bloc":611,"module":612,"attribution":613,"client":614,"projet":615,"tache":616,"intervention":617}
|
||||
```
|
||||
|
||||
Container Redis dedie pour bridge (separe du docmost-redis interne) :
|
||||
```bash
|
||||
docker run -d --name bridge-redis -p 127.0.0.1:6379:6379 redis:7-alpine
|
||||
```
|
||||
|
||||
## Commits Forgejo selfhost (cumulés)
|
||||
|
||||
Session 2026-05-07 jour :
|
||||
```
|
||||
668576c chore: initial commit (55 files, 7986 insertions, conception complete)
|
||||
d510bdd ops: fix CI run + bump testcontainers + doc 19 sync bidirec
|
||||
991d172 ops(ci): trigger CI on main + disable auto deploy-staging Phase 0
|
||||
66ff909 ops(ci): add vitest config + sanity tests
|
||||
d8e8bde ops(ci): fix docker-build .env before compose
|
||||
ecb7a44 ops(infra): add Forgejo Actions Runner skeleton
|
||||
6724be6 feat(baserow): add seed script + Fast-App iteration 1 artifacts
|
||||
a0266b8 feat(baserow): add formulas pass + related field naming
|
||||
5d02977 feat(docmost): manual setup guide for iteration 3
|
||||
8a676d2 feat(docmost): add seed.py via internal endpoints
|
||||
d5558ca fix(docmost-seed): handle data envelope + add format field
|
||||
1d71364 feat(seed): add I4 forms publics + space etudiant + I5 healthcheck
|
||||
7d4d2cd feat(agents): create bridge-dev specialized agent (1st BYAN INT)
|
||||
b37220d feat(agents): complete BYAN INT for 3 more agents + session resume MD
|
||||
460f7ef feat(workflows): create 5 BYAN workflows for agent collaboration
|
||||
```
|
||||
|
||||
Session 2026-05-07 soir :
|
||||
```
|
||||
5b2abbc feat(bridge/adapters): bloc 1 propre — BaserowClient + DocmostClient + RedisCache
|
||||
2c5665b feat(bridge/domain): bloc 2 — domain models + tests Vitest (coverage 97.86%)
|
||||
c8e9b4d feat(bridge): bloc 3 — routes REST Tier 1 + auth + repos Baserow (10 endpoints)
|
||||
7a3fbe4 fix(bridge): smoke test fixes — skip rows malformees + BASEROW_TABLE_IDS override
|
||||
[NEXT] docs(session): update SESSION-RESUME apres Bloc 1+2+3 + smoke test
|
||||
```
|
||||
|
||||
## Memoire BYAN persistee
|
||||
|
||||
`/home/imugiii/.claude/projects/-home-imugiii-Documents-jsap/memory/` :
|
||||
- `user_role.md` : Corentin JOGUET profil + role chez Acadenice
|
||||
- `project_notion_like.md` : projet detaille, scope, stack, decisions, IDs externes
|
||||
- `reference_outline.md` : Outline wiki Acadenice config + endpoints
|
||||
|
||||
## Pour la prochaine session — checklist demarrage
|
||||
|
||||
```
|
||||
[ ] Lire ce SESSION-RESUME.md (ce CHANGELOG en haut + section "Etat des blocs Phase 2")
|
||||
[ ] Verifier stack locale up : docker compose ps + docker ps | grep bridge-redis
|
||||
(si bridge-redis absent : docker run -d --name bridge-redis -p 127.0.0.1:6379:6379 redis:7-alpine)
|
||||
[ ] Verifier git pull a jour : cd formation-hub && git pull
|
||||
[ ] Verifier bridge boot : cd bridge && npm run dev (logs dans /tmp/bridge-smoke.log si en background)
|
||||
[ ] Smoke quick : curl http://localhost:4000/api/ready
|
||||
[ ] Decider quoi attaquer en premier (cf section "Vote pour la prochaine session" en haut) :
|
||||
- Option A : Bloc 7 — webhooks Baserow + sync bidirec (gros, recommande)
|
||||
- Option B : Bloc 5 — rate limit + cache invalidation (court, prerequis)
|
||||
- Option C : Bloc 6 — tests integration adapters via bridge-tester
|
||||
- Option D : Bloc 3.2 — refactor erreurs domain typees + routes restantes
|
||||
```
|
||||
|
||||
## Fast-App workflow local (artefacts dans `_byan-output/fast-app/formation-hub/`)
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---------|---------|
|
||||
| pitch.json | Validated 2026-05-07 |
|
||||
| backlog.json | 15 features MoSCoW + 5 WONT validated |
|
||||
| cdcf-stories.json | 10 stories Connextra+Gherkin pour Phase 1 |
|
||||
| plan.json | 7 iterations BUILD |
|
||||
| dispatch.json | Repartition Claude/Corentin/equipe |
|
||||
| build-state.json | current_iteration: 1, completed phases 1-6 (workflow Fast-App) |
|
||||
|
||||
---
|
||||
|
||||
**Tao Acadenice respecte tout au long** : direct, structures avec tirets, zero emoji, orientation solution.
|
||||
|
||||
Pret pour la suite. Bonne session.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"scope_mode": "full",
|
||||
"features": [
|
||||
{"id": "F-01", "title": "Wiki Docmost avec spaces multi-tenant", "priority": "MUST", "justification": "Coeur metier — centralisation doc"},
|
||||
{"id": "F-02", "title": "Diagrammes natifs Mermaid + Drawio + Excalidraw", "priority": "MUST", "justification": "Inclus Docmost v0.3+, zero dev"},
|
||||
{"id": "F-03", "title": "Permissions hierarchiques (workspace/space/page)", "priority": "MUST", "justification": "RGPD + workflow team"},
|
||||
{"id": "F-04", "title": "Share links externes (clients guests)", "priority": "MUST", "justification": "Acces partenaires/financeurs"},
|
||||
{"id": "F-05", "title": "9 tables Baserow (PERSONNE pivot + CFA + Agence)", "priority": "MUST", "justification": "Modele de donnees scope B approved"},
|
||||
{"id": "F-06", "title": "Rollups + formulas heures restantes", "priority": "MUST", "justification": "Calcul auto capacite formateurs/devs"},
|
||||
{"id": "F-07", "title": "Vues kanban/calendar/timeline par DB", "priority": "MUST", "justification": "UX metier admin"},
|
||||
{"id": "F-08", "title": "Spaces personnels etudiants Docmost", "priority": "MUST", "justification": "Promesse Vision Acadenice"},
|
||||
{"id": "F-09", "title": "Forms publics saisie heures (formateurs/devs)", "priority": "SHOULD", "justification": "UX mobile-friendly + permissions simples"},
|
||||
{"id": "F-10", "title": "Bridge service Tiptap node-views custom", "priority": "SHOULD", "justification": "UX unifie Phase 2"},
|
||||
{"id": "F-11", "title": "Sync bidirectionnel Docmost ↔ Baserow", "priority": "SHOULD", "justification": "Auto-creation pages depuis projets, etc."},
|
||||
{"id": "F-12", "title": "MCP server pour Claude/agents IA", "priority": "COULD", "justification": "Productivity boost pour admin Yan/Corentin"},
|
||||
{"id": "F-13", "title": "Bidirec backlinks Docmost (custom)", "priority": "COULD", "justification": "Nice-to-have, le bridge couvre 80% du besoin via DB relations"},
|
||||
{"id": "F-14", "title": "Dual-mode editor (WYSIWYG + raw markdown)", "priority": "COULD", "justification": "Power-users only"},
|
||||
{"id": "F-15", "title": "Rapports PDF (formation/personne/projet)", "priority": "COULD", "justification": "Export pour comptabilite, Phase 3"}
|
||||
],
|
||||
"wont": [
|
||||
{"id": "W-01", "title": "Modeliser les etudiants en table Baserow", "reason": "Decision Corentin 2026-05-07 : etudiants restent users Docmost libres, pas de modelisation structuree (inscriptions/notes). Si besoin Phase 4."},
|
||||
{"id": "W-02", "title": "Generer factures clients automatiquement", "reason": "Hors scope outil de connaissance. Comptabilite via outil dedie."},
|
||||
{"id": "W-03", "title": "ATS / recrutement", "reason": "Pas le metier, hors scope."},
|
||||
{"id": "W-04", "title": "Application mobile native", "reason": "Responsive web suffit. Mobile-native couterait 6 mois. Ockham."},
|
||||
{"id": "W-05", "title": "Multi-tenant (plusieurs centres de formation sur instance)", "reason": "Acadenice mono-instance pour l'instant. A reevaluer si scale."}
|
||||
],
|
||||
"validated_at": "2026-05-07",
|
||||
"validated_by": "Corentin JOGUET"
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"current_iteration": 1,
|
||||
"completed": [],
|
||||
"pending": [1, 2, 3, 4, 5, 6, 7],
|
||||
"next_iteration": 1,
|
||||
"stack_local_status": {
|
||||
"docmost": "up + healthy on http://localhost:3000",
|
||||
"baserow": "up + healthy on http://localhost:8080",
|
||||
"docmost-db": "up + healthy",
|
||||
"docmost-redis": "up",
|
||||
"verified_at": "2026-05-07T14:48:00Z"
|
||||
},
|
||||
"ready_for_iteration_1": true,
|
||||
"iteration_1_first_step": "Creer compte admin Baserow via http://localhost:8080 (1ere page invite a creer un compte). Apres : creer database 'formation-hub', puis table 'personne'."
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
{
|
||||
"scope": "Phase 1 vanilla setup metier — focalise BUILD iteration 1-3",
|
||||
"stories": [
|
||||
{
|
||||
"id": "S-01",
|
||||
"feature_id": "F-01",
|
||||
"connextra": "En tant qu'Admin Acadenice, je veux creer un workspace Docmost et 3 spaces (CFA, Agence, Interne), afin de centraliser la doc avec une structure miroir des collections Outline existantes.",
|
||||
"ac": [
|
||||
{"given": "compte admin Docmost cree", "when": "je cree workspace 'Acadenice formation-hub'", "then": "workspace existe avec moi en owner"},
|
||||
{"given": "workspace cree", "when": "je cree spaces CFA/Agence/Interne avec permissions par defaut 'workspace members'", "then": "3 spaces visibles sidebar"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-02",
|
||||
"feature_id": "F-05",
|
||||
"connextra": "En tant qu'Admin, je veux creer la table PERSONNE dans Baserow avec tous ses champs et formulas selon doc 15 MPD section 2, afin d'avoir le pivot multi-roles operationnel.",
|
||||
"ac": [
|
||||
{"given": "database 'formation-hub' creee", "when": "je cree la table PERSONNE avec 16 fields (incluant capacity_annuelle, split_pcts, roles multi-select, formulas heures_restantes)", "then": "la table est listee dans la database et les fields ont les bons types"},
|
||||
{"given": "table PERSONNE creee", "when": "je cree une row test (Yan, role formateur+developpeur, capacity 1500)", "then": "les formulas heures_restantes affichent 750/750/1500"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-03",
|
||||
"feature_id": "F-05",
|
||||
"connextra": "En tant qu'Admin, je veux creer les 4 tables CFA (FORMATION → BLOC → MODULE → ATTRIBUTION) avec leurs liens FK et rollups, afin que le suivi des heures formation soit operationnel.",
|
||||
"ac": [
|
||||
{"given": "table PERSONNE existe", "when": "je cree FORMATION, BLOC, MODULE, ATTRIBUTION dans l'ordre avec liens vers PERSONNE pour ATTRIBUTION", "then": "les 4 tables existent avec les liens visibles bidirectionnellement"},
|
||||
{"given": "tables CFA crees", "when": "je cree formation test avec 1 bloc + 1 module + 1 attribution a Yan", "then": "les rollups formation_heures_attribuees, bloc_heures_attribuees, module_heures_attribuees sont calcules"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-04",
|
||||
"feature_id": "F-05",
|
||||
"connextra": "En tant qu'Admin, je veux creer les 4 tables Agence (CLIENT → PROJET → TACHE → INTERVENTION) avec liens FK et rollups, afin de tracer les projets clients.",
|
||||
"ac": [
|
||||
{"given": "table PERSONNE existe", "when": "je cree CLIENT, PROJET, TACHE, INTERVENTION avec liens", "then": "tables existent + lien optionnel PROJET ↔ FORMATION pour projet pedagogique"},
|
||||
{"given": "tables creees", "when": "j'ajoute client Centralis Europe + projet test + tache + intervention", "then": "les rollups projet_heures_realisees + tache_heures_realisees calculent"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-05",
|
||||
"feature_id": "F-07",
|
||||
"connextra": "En tant qu'Admin, je veux creer les vues recommandees doc 15 par table (table, kanban, calendar) afin d'avoir l'UX metier prete pour onboarding.",
|
||||
"ac": [
|
||||
{"given": "tables existent", "when": "je cree vue 'A attribuer' kanban sur MODULE group by module_statut", "then": "vue affiche kanban fonctionnel"},
|
||||
{"given": "vues creees", "when": "je verifie les vues principales par table (Tous, Kanban, Calendar selon doc 15)", "then": "minimum 9 vues fonctionnelles (~1 par table)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-06",
|
||||
"feature_id": "F-09",
|
||||
"connextra": "En tant que Formateur, je veux saisir mes heures realisees via un form public Baserow sans compte, afin de logger rapidement depuis mobile.",
|
||||
"ac": [
|
||||
{"given": "table ATTRIBUTION existe", "when": "Admin cree form view publique 'Saisir heures realisees' sur ATTRIBUTION (champs limites)", "then": "formateur peut acceder par lien et soumettre"},
|
||||
{"given": "form public actif", "when": "formateur saisit attribution + heures + date", "then": "row creee, rollups recalcules"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-07",
|
||||
"feature_id": "F-04",
|
||||
"connextra": "En tant qu'Admin, je veux generer un share link Docmost pour une page support de formation, afin qu'un client puisse consulter sans creer de compte.",
|
||||
"ac": [
|
||||
{"given": "page Docmost existante", "when": "je clique 'Share' et configure expiration 7j + password", "then": "lien genere fonctionne en navigation privee sans login"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-08",
|
||||
"feature_id": "F-08",
|
||||
"connextra": "En tant qu'Admin, je veux pouvoir creer rapidement un space personnel pour un nouvel etudiant avec template, afin de l'onboarder en moins de 2 min.",
|
||||
"ac": [
|
||||
{"given": "Docmost workspace 'Acadenice formation-hub'", "when": "je cree space 'Etudiant - Marie Dupont' visibility prive (Marie + admins)", "then": "space cree, Marie peut creer/editer ses pages, autres etudiants ne le voient pas"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-09",
|
||||
"feature_id": "F-05",
|
||||
"connextra": "En tant qu'Admin, je veux generer un API token Baserow scope read+write pour le bridge service Phase 2, afin que le code custom puisse interroger les tables.",
|
||||
"ac": [
|
||||
{"given": "database formation-hub ok", "when": "je cree API token via Settings → API tokens", "then": "token genere fonctionne sur curl GET /api/database/rows/table/X/"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "S-10",
|
||||
"feature_id": "F-01",
|
||||
"connextra": "En tant qu'Admin, je veux backup quotidien automatique de Postgres docmost + data Baserow + uploads, afin d'avoir RPO 24h conforme CDC.",
|
||||
"ac": [
|
||||
{"given": "stack up", "when": "le cron quotidien 03:00 execute scripts/backup.sh", "then": "fichiers .sql.gz et .tar.gz crees dans backups/local/, retention 30 jours"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"validated_at": null,
|
||||
"next_step": "PLAN d'iterations BUILD"
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
{
|
||||
"assignments": [
|
||||
{
|
||||
"iteration_idx": 1,
|
||||
"specialist": "Claude Code (Sonnet 4.6) + Corentin",
|
||||
"model_tier": "haiku",
|
||||
"rationale": "Setup Baserow tables = action UI repetitive. Claude guide etape par etape, Corentin clique. Pas besoin de gros raisonnement.",
|
||||
"automation_possible": "Partiel — l'API Baserow permet de creer tables/fields programmatiquement, mais l'UI Baserow est plus rapide pour le 1er setup"
|
||||
},
|
||||
{
|
||||
"iteration_idx": 2,
|
||||
"specialist": "Claude Code + Corentin",
|
||||
"model_tier": "sonnet",
|
||||
"rationale": "Formulas Baserow ont une syntaxe specifique (field('x'), lookup, count, sum). Sonnet pour bien syntaxer les formulas du doc 15.",
|
||||
"automation_possible": "Partiel"
|
||||
},
|
||||
{
|
||||
"iteration_idx": 3,
|
||||
"specialist": "Corentin solo",
|
||||
"model_tier": "haiku",
|
||||
"rationale": "Setup Docmost UI = action de config standard. Pas besoin Claude.",
|
||||
"automation_possible": "Faible — Docmost API est limitee pour creation workspace/spaces"
|
||||
},
|
||||
{
|
||||
"iteration_idx": 4,
|
||||
"specialist": "Corentin + (eventuellement Claude pour script create-space-etudiant)",
|
||||
"model_tier": "haiku",
|
||||
"rationale": "Pattern repetitif → automatisable via script bash + Docmost API",
|
||||
"automation_possible": "Oui via script"
|
||||
},
|
||||
{
|
||||
"iteration_idx": 5,
|
||||
"specialist": "Corentin (DevOps son metier)",
|
||||
"model_tier": "haiku",
|
||||
"rationale": "API token + cron + smoke = du DevOps pur. Corentin maitrise.",
|
||||
"automation_possible": "100% script"
|
||||
},
|
||||
{
|
||||
"iteration_idx": 6,
|
||||
"specialist": "Corentin + Yan + Sophie",
|
||||
"model_tier": "n/a (humain)",
|
||||
"rationale": "Migration data necessite knowledge metier (qui est qui dans les RH, quels clients, etc.). Pas Claude — humains internes.",
|
||||
"automation_possible": "Partiel : Claude peut transformer CSV → format Baserow API"
|
||||
},
|
||||
{
|
||||
"iteration_idx": 7,
|
||||
"specialist": "Equipe Acadenice (Yan, Ludo, Corentin) + 5-10 testeurs",
|
||||
"model_tier": "n/a (humain)",
|
||||
"rationale": "Test reel necessite humains. Claude peut compiler les retours en backlog priorise apres.",
|
||||
"automation_possible": "Non"
|
||||
}
|
||||
],
|
||||
"halt": null,
|
||||
"validated_at": null
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"name_app": "formation-hub",
|
||||
"one_liner": "Notion-like self-host pour Acadenice (CFA + Agence dev) avec suivi heures formateurs/devs unifie",
|
||||
"who": "Equipe Acadenice (~20 employes : direction Ludo, resp tech Yan, AdminSys/DevOps Corentin, formateurs, devs) + ~70 etudiants (spaces personnels libres) + clients guests (acces lien partage). Cible totale : 90-100 users, ~30 simultanes peak.",
|
||||
"what": "Plateforme self-host composite : (1) Wiki collaboratif (Docmost AGPL) avec diagrammes natifs Mermaid/Drawio/Excalidraw + share links + spaces multi-tenant. (2) Bases de donnees structurees (Baserow MIT) pour le suivi heures formation/agence avec entite PERSONNE pivot multi-roles. (3) Bridge custom Node TS (Phase 2) qui synchronise Docmost et Baserow bidirectionnel et expose des Tiptap nodes custom pour UX unifie. (4) MCP server (Phase 3) pour interaction Claude/agents IA.",
|
||||
"why": "Centraliser la doc + suivre les heures formation/agence dans un outil unifie self-host **illimite users**. Alternatives ecartees : Notion paye au seat, AFFiNE limite a 10 seats free, AppFlowy limite a 1 user free, Outline pas de bidirec backlinks. Acadenice a une double casquette CFA + Agence dev (formateurs = devs sur projets clients) = capacite annuelle splittee entre les deux activites. Aucun outil existant ne modelise ca correctement.",
|
||||
"context": "Phase 0 conception complete (19 docs Merise Agile + UML + GitOps). Repo : github.com/AcadeNice/wiki + git.acadenice.com/AcadeNice/Wiki (selfhost source of truth). Stack Docker compose locale up et healthy au 2026-05-07.",
|
||||
"validated_at": "2026-05-07",
|
||||
"validated_by": "Corentin JOGUET"
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
{
|
||||
"iterations": [
|
||||
{
|
||||
"idx": 1,
|
||||
"name": "I1 — Setup Baserow vanilla (tables + liens)",
|
||||
"stories": ["S-02", "S-03", "S-04"],
|
||||
"expected_loops": 2,
|
||||
"definition_of_done": "9 tables creees dans Baserow database 'formation-hub' avec tous les liens FK fonctionnels (testes manuellement avec rows-temoin). Pas encore de formulas/rollups complexes — juste structure.",
|
||||
"deliverable": "Schema Baserow exporte JSON dans baserow/schemas/*.json + screenshots de chaque table"
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"name": "I2 — Formulas, rollups, vues",
|
||||
"stories": ["S-02 (formulas part)", "S-05"],
|
||||
"expected_loops": 2,
|
||||
"definition_of_done": "Toutes les formulas du doc 15 sont actives + 9+ vues recommandees (kanban, calendar, table) crees. Rows test confirment les calculs.",
|
||||
"deliverable": "Vues exportees + screenshots dashboards"
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"name": "I3 — Setup Docmost workspace + permissions + share",
|
||||
"stories": ["S-01", "S-07"],
|
||||
"expected_loops": 2,
|
||||
"definition_of_done": "Workspace + 3 spaces + permissions par defaut + 1 page test partagee par lien public expire 7j",
|
||||
"deliverable": "Captures workspace + URL share test"
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"name": "I4 — Spaces etudiants + form public saisie heures",
|
||||
"stories": ["S-08", "S-06"],
|
||||
"expected_loops": 2,
|
||||
"definition_of_done": "Pattern create-space-etudiant valide (script ou checklist 2-min) + form public Baserow ATTRIBUTION accessible mobile",
|
||||
"deliverable": "Doc onboarding etudiant + URL form public"
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"name": "I5 — API token + backup automatise + smoke test E2E",
|
||||
"stories": ["S-09", "S-10"],
|
||||
"expected_loops": 1,
|
||||
"definition_of_done": "Token Baserow fonctionnel + cron backup setup + scripts/healthcheck.sh + scripts/smoke-test.sh passent",
|
||||
"deliverable": "Token stocke (vault/.env), 1ere execution backup reussie, logs"
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"name": "I6 — Migration data initiale (formations + clients existants)",
|
||||
"stories": ["data migration"],
|
||||
"expected_loops": 3,
|
||||
"definition_of_done": "Donnees reelles Acadenice importees depuis sources actuelles (Excel/Trello/autre) dans Baserow, integrite verifiee (rollups coherents avec realite metier)",
|
||||
"deliverable": "Rapport migration : nb rows attendus vs imported, cas speciaux"
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"name": "I7 — Onboarding 5-10 power users + retours UX",
|
||||
"stories": ["onboarding"],
|
||||
"expected_loops": 2,
|
||||
"definition_of_done": "5-10 personnes Acadenice (Yan, Ludo, Sophie, 2-3 formateurs, 2 devs) ont utilise pendant 1 semaine + retours collectes",
|
||||
"deliverable": "Backlog UX issues priorise"
|
||||
}
|
||||
],
|
||||
"phases_apres_plan": [
|
||||
"Iterations Phase 2 (bridge custom) seront planifiees apres I7 selon douleurs reelles identifiees",
|
||||
"MPD Baserow concret est dans doc 15-baserow-mpd.md"
|
||||
],
|
||||
"validated_at": null
|
||||
}
|
||||
|
|
@ -6,10 +6,23 @@ NODE_ENV=development
|
|||
PORT=4000
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Baserow API
|
||||
# Baserow API — DB token (CRUD rows)
|
||||
BASEROW_API_URL=http://baserow:80/api
|
||||
BASEROW_API_TOKEN=
|
||||
|
||||
# Baserow service account — user JWT (endpoints metadata : views, tables detail)
|
||||
# Le DB token (BASEROW_API_TOKEN) renvoie 401 PERMISSION_DENIED sur les endpoints
|
||||
# comme GET /api/database/views/table/:id/. Un compte Baserow dedie resout ce probleme.
|
||||
#
|
||||
# Creer le compte dans l'interface Baserow : /admin/users/ > Add user.
|
||||
# Privileges requis : acces en lecture a la database concernee (pas besoin d'admin).
|
||||
# Exemple : email=bridge-svc@interne.local, mot de passe fort genere.
|
||||
# Si absent, les routes /api/v1/views/* renvoient 500 BASEROW_USER_AUTH_NOT_CONFIGURED.
|
||||
#
|
||||
# BASEROW_USER_EMAIL=bridge-svc@interne.local
|
||||
# BASEROW_USER_PASSWORD=generated-strong-password-here
|
||||
# BASEROW_JWT_REFRESH_MARGIN=60 # secondes avant expiry ou le bridge refresh (defaut 60)
|
||||
|
||||
# Docmost API (optionnel — pas utilise par le bridge generique R1)
|
||||
# DOCMOST_API_URL=http://docmost:3000/api
|
||||
# DOCMOST_API_TOKEN=
|
||||
|
|
|
|||
143
bridge/docs/baserow-auth.md
Normal file
143
bridge/docs/baserow-auth.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Baserow Authentication — Bridge Service
|
||||
|
||||
## Pourquoi deux types de tokens
|
||||
|
||||
Baserow expose deux mecanismes d'authentification aux comportements differents :
|
||||
|
||||
| Type | Header | Droits | Usages |
|
||||
|------|--------|--------|--------|
|
||||
| DB token | `Token brg_*` | CRUD rows uniquement | `listRows`, `createRow`, `updateRow`, `deleteRow` |
|
||||
| User JWT | `JWT eyJ...` | Toutes les routes API | Metadata : `listViews`, `getTable`, `resolveTableIds` |
|
||||
|
||||
Le DB token (BASEROW_API_TOKEN) est suffisant pour 90% des cas mais Baserow renvoie
|
||||
`401 PERMISSION_DENIED` sur les endpoints metadata. Ce n'est pas un bug : c'est la
|
||||
conception Baserow qui distingue DB tokens (CRUD rows) des user sessions (lecture
|
||||
full-API).
|
||||
|
||||
Le bridge utilise donc les deux :
|
||||
- **DB token** : routes `/api/database/rows/table/:id/` — toujours actif
|
||||
- **User JWT** : routes `/api/database/views/table/:id/`, `/api/database/tables/:id/` — actif si `BASEROW_USER_EMAIL` + `BASEROW_USER_PASSWORD` sont configures
|
||||
|
||||
## API Baserow — endpoints JWT
|
||||
|
||||
Source : docs.baserow.io + github.com/code-watch/baserow/blob/master/docs/getting-started/api.md
|
||||
|
||||
### Login
|
||||
|
||||
```
|
||||
POST /api/user/token-auth/
|
||||
Content-Type: application/json
|
||||
|
||||
{ "username": "email@example.com", "password": "..." }
|
||||
```
|
||||
|
||||
Reponse :
|
||||
```json
|
||||
{ "token": "eyJ..." }
|
||||
```
|
||||
|
||||
Le JWT est valide 60 minutes (valeur par defaut Baserow, configurable cote serveur).
|
||||
|
||||
### Refresh
|
||||
|
||||
```
|
||||
POST /api/user/token-refresh/
|
||||
Content-Type: application/json
|
||||
|
||||
{ "token": "eyJ..." (token courant) }
|
||||
```
|
||||
|
||||
Reponse :
|
||||
```json
|
||||
{ "token": "eyJ..." (nouveau token) }
|
||||
```
|
||||
|
||||
### Header d'autorisation
|
||||
|
||||
```
|
||||
Authorization: JWT eyJ...
|
||||
```
|
||||
|
||||
Note : le prefixe est `JWT`, pas `Bearer` ni `Token`.
|
||||
|
||||
## Creer le compte service Baserow
|
||||
|
||||
1. Ouvrir `http://<baserow-host>/admin/users/`
|
||||
2. Cliquer "Add user"
|
||||
3. Remplir :
|
||||
- Email : `bridge-svc@interne.local` (ou equivalent interne)
|
||||
- Password : generer un mot de passe fort (min 16 chars, stocker dans Vault ou secret manager)
|
||||
- Is active : oui
|
||||
- Is staff : non (pas besoin d'admin)
|
||||
4. Ajouter ce user aux groupes (workspaces) ou il doit lire les tables
|
||||
- Permission "Member" suffit pour lire views + tables
|
||||
5. Verifier : `curl -X POST http://baserow/api/user/token-auth/ -d '{"username":"bridge-svc@...","password":"..."}'`
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
```bash
|
||||
# Requis pour activer le JWT manager
|
||||
BASEROW_USER_EMAIL=bridge-svc@interne.local
|
||||
BASEROW_USER_PASSWORD=generated-strong-password
|
||||
|
||||
# Optionnel — refresh le JWT N secondes avant son expiration (defaut 60)
|
||||
BASEROW_JWT_REFRESH_MARGIN=60
|
||||
```
|
||||
|
||||
Si ces variables sont absentes, le bridge continue a fonctionner pour les CRUD rows.
|
||||
Les routes views renvoient `500 BASEROW_USER_AUTH_NOT_CONFIGURED`.
|
||||
|
||||
## Verification que ca marche
|
||||
|
||||
### Via le health endpoint
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:4000/api/health
|
||||
# { "status": "ok", "service": "bridge", "version": "0.1.0" }
|
||||
```
|
||||
|
||||
### Via la route views
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer brg_<votre-token>" \
|
||||
"http://localhost:4000/api/v1/views/table/personne"
|
||||
# { "data": [...], "total": N }
|
||||
```
|
||||
|
||||
Avant Patch 031, cette route retournait 401 de Baserow.
|
||||
Apres Patch 031 avec JWT configure, elle retourne les vues de la table.
|
||||
|
||||
### Verifier les logs au boot
|
||||
|
||||
```
|
||||
INFO: Baserow user JWT manager enabled (email: bridge-svc@interne.local)
|
||||
```
|
||||
|
||||
Si vous voyez a la place :
|
||||
```
|
||||
INFO: Baserow user JWT manager disabled — metadata endpoints will return 503 if called
|
||||
```
|
||||
c'est que BASEROW_USER_EMAIL ou BASEROW_USER_PASSWORD est absent.
|
||||
|
||||
## Architecture interne
|
||||
|
||||
```
|
||||
getToken() appel ─► BaserowJwtManagerImpl
|
||||
├─ token cache frais ? → retour immediat
|
||||
├─ token bientot expire ? → POST /api/user/token-refresh/
|
||||
│ └─ echec ? → fallback POST /api/user/token-auth/
|
||||
└─ pas de token ? → POST /api/user/token-auth/
|
||||
|
||||
Mutex : si 10 appels simultanes → 1 seul login/refresh, les 9 autres attendent.
|
||||
```
|
||||
|
||||
`BaserowClient.listViewsWithJwt(tableId, jwt)` utilise `Authorization: JWT <jwt>` vers Baserow.
|
||||
|
||||
## Bonnes pratiques securite
|
||||
|
||||
- Le password est remplace par `***` dans les logs (voir `baserow-jwt-manager.ts`, appels `this.logger.error`)
|
||||
- Le JWT reste en memoire uniquement (pas de Redis, pas de fichier disque)
|
||||
- Le JWT n'est pas retransmis dans les reponses HTTP du bridge vers les clients
|
||||
- En cas de rotation du mot de passe Baserow : redemarrer le bridge (re-login au premier appel)
|
||||
- Stocker BASEROW_USER_PASSWORD dans un secret manager (Vault, Docker secrets, k8s Secret)
|
||||
|
|
@ -70,6 +70,72 @@ export class BaserowClient {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch variant using a user JWT instead of the DB token.
|
||||
* Required for metadata endpoints: GET /api/database/views/table/:id/,
|
||||
* GET /api/database/tables/:id/, etc.
|
||||
* Authorization format: "JWT <token>" (Baserow docs, CLAIM L3).
|
||||
*/
|
||||
private fetchWithUserJwt<T>(
|
||||
path: string,
|
||||
userJwt: string,
|
||||
init?: { method?: string; body?: string },
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
return ofetch<T>(url, {
|
||||
method: init?.method,
|
||||
body: init?.body,
|
||||
headers: {
|
||||
Authorization: `JWT ${userJwt}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
retry: 1,
|
||||
retryDelay: 200,
|
||||
timeout: 10_000,
|
||||
onResponseError: ({ response }) => {
|
||||
this.logger.error(
|
||||
{ status: response.status, url, body: response._data },
|
||||
'baserow user jwt error',
|
||||
);
|
||||
},
|
||||
}).catch((err: unknown) => {
|
||||
const error = err as { response?: { status?: number } };
|
||||
if (error.response?.status === 401) throw errors.authInvalid();
|
||||
if (error.response?.status === 404) throw errors.notFound('Baserow resource', path);
|
||||
if (!error.response) throw errors.baserowDown();
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les vues d'une table avec un JWT user.
|
||||
* Remplace `listViews` qui utilise le DB token (401 sur cet endpoint).
|
||||
*/
|
||||
async listViewsWithJwt(
|
||||
tableId: number,
|
||||
userJwt: string,
|
||||
): Promise<
|
||||
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||
> {
|
||||
return this.fetchWithUserJwt<
|
||||
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||
>(`/api/database/views/table/${tableId}/`, userJwt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere les metadata d'une table avec un JWT user.
|
||||
* Le DB token renvoie 401 sur cet endpoint.
|
||||
*/
|
||||
async getTableWithJwt(
|
||||
tableId: number,
|
||||
userJwt: string,
|
||||
): Promise<{ id: number; name: string; order: number; database_id: number }> {
|
||||
return this.fetchWithUserJwt<{ id: number; name: string; order: number; database_id: number }>(
|
||||
`/api/database/tables/${tableId}/`,
|
||||
userJwt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les rows d'une table avec pagination.
|
||||
* Retourne automatiquement les noms de fields (`user_field_names=true`) pour eviter le mapping field_id.
|
||||
|
|
|
|||
285
bridge/src/lib/baserow-jwt-manager.ts
Normal file
285
bridge/src/lib/baserow-jwt-manager.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/**
|
||||
* BaserowJwtManager — auto-login service account pattern.
|
||||
*
|
||||
* Pourquoi ce module existe :
|
||||
* Le DB token Baserow (Token brg_*) suffit pour les CRUD rows mais Baserow
|
||||
* renvoie 401 PERMISSION_DENIED sur les endpoints metadata (views, tables
|
||||
* detail). Ces endpoints necessitent un JWT user obtenu via token-auth.
|
||||
*
|
||||
* Pattern :
|
||||
* - Lazy init : pas de login au boot, premier getToken() declenche le login.
|
||||
* - Cache memoire : le JWT est conserve jusqu'a `exp - refreshMarginSeconds`.
|
||||
* - Refresh : POST /api/user/token-refresh/ avec le token courant avant expiry.
|
||||
* - Mutex : un seul refresh concurrent (lock promesse partage).
|
||||
* - Fallback : si creds absentes, isEnabled() = false ; getToken() leve une
|
||||
* erreur BASEROW_USER_AUTH_NOT_CONFIGURED que le caller transforme en 503.
|
||||
*
|
||||
* Sources :
|
||||
* - baserow.io/docs/apis/rest-api : POST /api/user/token-auth/ (CLAIM L3)
|
||||
* - github.com/code-watch/baserow/blob/master/docs/getting-started/api.md : JWT 60min (CLAIM L3)
|
||||
* - community.baserow.io/t/jwt-token-authentication/4138 : email not username (CLAIM L3)
|
||||
*/
|
||||
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BaserowJwtManager {
|
||||
/** Returns a valid JWT. Refreshes if close to expiry. */
|
||||
getToken(): Promise<string>;
|
||||
/** True if user credentials are configured. */
|
||||
isEnabled(): boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenAuthResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface DecodedJwtPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decode the payload of a JWT without verifying signature.
|
||||
* Used only to read `exp` so we know when to refresh.
|
||||
*/
|
||||
function decodeJwtPayload(token: string): DecodedJwtPayload {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return {};
|
||||
try {
|
||||
const raw = parts[1];
|
||||
if (!raw) return {};
|
||||
// atob is available in Node 22 globalThis. Buffer fallback for safety.
|
||||
const decoded =
|
||||
typeof atob === 'function'
|
||||
? atob(raw.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
: Buffer.from(raw, 'base64url').toString('utf8');
|
||||
return JSON.parse(decoded) as DecodedJwtPayload;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BaserowJwtManagerImpl implements BaserowJwtManager {
|
||||
private readonly baseUrl: string;
|
||||
private readonly email: string;
|
||||
private readonly password: string;
|
||||
private readonly refreshMarginSeconds: number;
|
||||
private readonly logger: Logger;
|
||||
|
||||
private cachedToken: string | null = null;
|
||||
private tokenExp: number | null = null; // unix timestamp seconds
|
||||
// Lock: prevents concurrent logins/refreshes under burst conditions.
|
||||
private inflightRefresh: Promise<string> | null = null;
|
||||
|
||||
constructor(opts: {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
password: string;
|
||||
refreshMarginSeconds: number;
|
||||
logger: Logger;
|
||||
}) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.email = opts.email;
|
||||
this.password = opts.password;
|
||||
this.refreshMarginSeconds = opts.refreshMarginSeconds;
|
||||
this.logger = opts.logger.child({ service: 'baserow-jwt-manager' });
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
// If we have a cached token that is still fresh, return it immediately.
|
||||
if (this.cachedToken !== null && this.isTokenFresh()) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// Dedup concurrent refresh: if a refresh is already in flight, await it.
|
||||
if (this.inflightRefresh !== null) {
|
||||
return this.inflightRefresh;
|
||||
}
|
||||
|
||||
this.inflightRefresh = this.acquireToken().finally(() => {
|
||||
this.inflightRefresh = null;
|
||||
});
|
||||
|
||||
return this.inflightRefresh;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private isTokenFresh(): boolean {
|
||||
if (this.tokenExp === null) return false;
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
return this.tokenExp - this.refreshMarginSeconds > nowSeconds;
|
||||
}
|
||||
|
||||
private storeToken(token: string): void {
|
||||
this.cachedToken = token;
|
||||
const payload = decodeJwtPayload(token);
|
||||
if (typeof payload.exp === 'number') {
|
||||
this.tokenExp = payload.exp;
|
||||
this.logger.debug(
|
||||
{ exp: this.tokenExp, margin: this.refreshMarginSeconds },
|
||||
'jwt token stored',
|
||||
);
|
||||
} else {
|
||||
// No exp claim — default 55 min from now to stay safe within 60min Baserow TTL.
|
||||
this.tokenExp = Math.floor(Date.now() / 1000) + 55 * 60;
|
||||
this.logger.warn('jwt token has no exp claim — defaulting to 55min TTL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a token: refresh if we have a stale cached token, else login from scratch.
|
||||
*/
|
||||
private async acquireToken(): Promise<string> {
|
||||
if (this.cachedToken !== null && !this.isTokenFresh()) {
|
||||
// Try refresh first; fall back to full login on failure.
|
||||
try {
|
||||
const token = await this.doRefresh(this.cachedToken);
|
||||
this.storeToken(token);
|
||||
this.logger.info('baserow jwt refreshed');
|
||||
return token;
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, 'baserow jwt refresh failed — falling back to full login');
|
||||
// Fall through to full login below.
|
||||
}
|
||||
}
|
||||
|
||||
const token = await this.doLogin();
|
||||
this.storeToken(token);
|
||||
this.logger.info('baserow jwt login succeeded');
|
||||
return token;
|
||||
}
|
||||
|
||||
private async doLogin(): Promise<string> {
|
||||
this.logger.debug({ email: this.email }, 'baserow jwt login');
|
||||
const url = `${this.baseUrl}/api/user/token-auth/`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: this.email, password: this.password }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
this.logger.error(
|
||||
{ status: res.status, email: this.email, password: '***' },
|
||||
'baserow jwt login failed',
|
||||
);
|
||||
throw new BaserowAuthError(
|
||||
`BRIDGE_BASEROW_AUTH_FAILED: login returned ${res.status} — ${body}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TokenAuthResponse;
|
||||
if (!data.token) {
|
||||
throw new BaserowAuthError('BRIDGE_BASEROW_AUTH_FAILED: response missing token field', 502);
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
private async doRefresh(currentToken: string): Promise<string> {
|
||||
this.logger.debug('baserow jwt refresh');
|
||||
const url = `${this.baseUrl}/api/user/token-refresh/`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: currentToken }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new BaserowAuthError(
|
||||
`BRIDGE_BASEROW_AUTH_FAILED: refresh returned ${res.status}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TokenAuthResponse;
|
||||
if (!data.token) {
|
||||
throw new BaserowAuthError(
|
||||
'BRIDGE_BASEROW_AUTH_FAILED: refresh response missing token field',
|
||||
502,
|
||||
);
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disabled stub (when creds not configured)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BaserowJwtManagerDisabled implements BaserowJwtManager {
|
||||
isEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getToken(): Promise<string> {
|
||||
return Promise.reject(
|
||||
new BaserowAuthError(
|
||||
'BASEROW_USER_AUTH_NOT_CONFIGURED: set BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD to enable metadata endpoints',
|
||||
503,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BaserowAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BaserowAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createBaserowJwtManager(opts: {
|
||||
baseUrl: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
refreshMarginSeconds: number;
|
||||
logger: Logger;
|
||||
}): BaserowJwtManager {
|
||||
if (opts.email && opts.password) {
|
||||
return new BaserowJwtManagerImpl({
|
||||
baseUrl: opts.baseUrl,
|
||||
email: opts.email,
|
||||
password: opts.password,
|
||||
refreshMarginSeconds: opts.refreshMarginSeconds,
|
||||
logger: opts.logger,
|
||||
});
|
||||
}
|
||||
return new BaserowJwtManagerDisabled();
|
||||
}
|
||||
|
|
@ -39,6 +39,37 @@ const ConfigSchema = z.object({
|
|||
// global (XADD MAXLEN ~). Défaut 10 000 events. Les events plus anciens sont
|
||||
// purgés automatiquement par Redis (mode ~ = approximatif, plus performant).
|
||||
streamMaxLen: z.coerce.number().int().positive().default(10_000),
|
||||
// Baserow service account — user JWT pour les endpoints metadata (views, tables detail).
|
||||
// Un DB token (Token brg_*) suffit pour CRUD rows mais renvoie 401 PERMISSION_DENIED
|
||||
// sur GET /api/database/views/table/:id/ etc. Un compte Baserow dedie resout ce probleme.
|
||||
// Creer le compte via /admin/users/ dans l'interface Baserow.
|
||||
// Si absent, les endpoints metadata renvoient 503 BASEROW_USER_AUTH_NOT_CONFIGURED.
|
||||
baserowUserEmail: z.string().email().optional(),
|
||||
baserowUserPassword: z.string().min(1).optional(),
|
||||
// Refresh le JWT `refreshMarginSeconds` secondes avant son expiration (defaut : 60s).
|
||||
// Baserow JWT valide 60min par defaut — refresh a t=59min.
|
||||
baserowJwtRefreshMargin: z.coerce.number().int().positive().default(60),
|
||||
// Optional slug -> table_id map so callers can use human-friendly slugs
|
||||
// (e.g. /api/v1/views/table/personne) instead of the numeric Baserow ID.
|
||||
// Format: JSON object string like '{"personne":609,"formation":610}'.
|
||||
baserowTableIds: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((raw) => {
|
||||
if (!raw) return {} as Record<string, number>;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'number' && Number.isInteger(v) && v > 0) {
|
||||
out[k.toLowerCase()] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
|
@ -68,6 +99,10 @@ export function loadConfig(): Config {
|
|||
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
||||
rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW,
|
||||
streamMaxLen: process.env.STREAM_MAXLEN,
|
||||
baserowUserEmail: process.env.BASEROW_USER_EMAIL,
|
||||
baserowUserPassword: process.env.BASEROW_USER_PASSWORD,
|
||||
baserowJwtRefreshMargin: process.env.BASEROW_JWT_REFRESH_MARGIN,
|
||||
baserowTableIds: process.env.BASEROW_TABLE_IDS,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { BaserowFieldsRepo } from '../repos/baserow-fields-repo.js';
|
|||
import { BaserowRowsRepo } from '../repos/baserow-rows-repo.js';
|
||||
import { BaserowTablesRepo } from '../repos/baserow-tables-repo.js';
|
||||
import { BaserowViewsRepo } from '../repos/baserow-views-repo.js';
|
||||
import type { BaserowJwtManager } from './baserow-jwt-manager.js';
|
||||
import { createBaserowJwtManager } from './baserow-jwt-manager.js';
|
||||
import type { Config } from './config.js';
|
||||
import { isDocmostJwtEnabled, isOidcEnabled } from './config.js';
|
||||
import { logger as rootLogger } from './logger.js';
|
||||
|
|
@ -43,6 +45,8 @@ export interface Container {
|
|||
docmostJwt: DocmostJwtVerifier | null;
|
||||
groupsScopesMap: GroupsScopesMap;
|
||||
logger: Logger;
|
||||
/** Gestionnaire JWT user Baserow pour endpoints metadata. Toujours present. */
|
||||
baserowJwt: BaserowJwtManager;
|
||||
}
|
||||
|
||||
let _container: Container | null = null;
|
||||
|
|
@ -76,11 +80,21 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
|||
});
|
||||
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
|
||||
|
||||
// JWT manager created before repos so views repo can inject it.
|
||||
// We create it early with a partial config (email/password may be undefined).
|
||||
const baserowJwtEarly = createBaserowJwtManager({
|
||||
baseUrl: config.baserowApiUrl,
|
||||
email: config.baserowUserEmail,
|
||||
password: config.baserowUserPassword,
|
||||
refreshMarginSeconds: config.baserowJwtRefreshMargin,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
const repos: RepoSet = {
|
||||
tables: new BaserowTablesRepo({ client: baserow, logger: rootLogger }),
|
||||
rows: new BaserowRowsRepo({ client: baserow, logger: rootLogger }),
|
||||
fields: new BaserowFieldsRepo({ client: baserow, logger: rootLogger }),
|
||||
views: new BaserowViewsRepo({ client: baserow, logger: rootLogger }),
|
||||
views: new BaserowViewsRepo({ client: baserow, logger: rootLogger, jwtManager: baserowJwtEarly }),
|
||||
};
|
||||
|
||||
const tokens = parseTokens(config.bridgeApiTokens);
|
||||
|
|
@ -118,6 +132,20 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
|||
rootLogger.info('DocAdenice JWT HMAC mode disabled');
|
||||
}
|
||||
|
||||
// baserowJwtEarly is already the definitive instance (created before repos).
|
||||
const baserowJwt = baserowJwtEarly;
|
||||
|
||||
if (baserowJwt.isEnabled()) {
|
||||
rootLogger.info(
|
||||
{ email: config.baserowUserEmail },
|
||||
'Baserow user JWT manager enabled',
|
||||
);
|
||||
} else {
|
||||
rootLogger.info(
|
||||
'Baserow user JWT manager disabled — metadata endpoints will return 503 if called',
|
||||
);
|
||||
}
|
||||
|
||||
const container: Container = {
|
||||
config,
|
||||
baserow,
|
||||
|
|
@ -128,6 +156,7 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
|||
docmostJwt,
|
||||
groupsScopesMap,
|
||||
logger: rootLogger,
|
||||
baserowJwt,
|
||||
};
|
||||
setContainer(container);
|
||||
return container;
|
||||
|
|
|
|||
|
|
@ -17,12 +17,17 @@ import type { Logger } from 'pino';
|
|||
import type { BaserowClient, BaserowListOptions } from '../adapters/baserow-client.js';
|
||||
import type { RedisCache } from '../adapters/redis-cache.js';
|
||||
import { Row } from '../domain/row.js';
|
||||
import type { BaserowJwtManager } from '../lib/baserow-jwt-manager.js';
|
||||
import { BaserowAuthError } from '../lib/baserow-jwt-manager.js';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import type { ViewFilter, ViewGroupBy, ViewSorting } from '../domain/view.js';
|
||||
import { View } from '../domain/view.js';
|
||||
|
||||
export interface BaserowViewsRepoOptions {
|
||||
client: BaserowClient;
|
||||
logger: Logger;
|
||||
/** JWT manager pour les endpoints metadata Baserow. */
|
||||
jwtManager?: BaserowJwtManager;
|
||||
}
|
||||
|
||||
export interface ListRowsResult {
|
||||
|
|
@ -135,10 +140,37 @@ function mapRawToView(
|
|||
export class BaserowViewsRepo {
|
||||
protected readonly client: BaserowClient;
|
||||
protected readonly logger: Logger;
|
||||
protected readonly jwtManager: BaserowJwtManager | undefined;
|
||||
|
||||
constructor(opts: BaserowViewsRepoOptions) {
|
||||
this.client = opts.client;
|
||||
this.logger = opts.logger.child({ repo: 'views' });
|
||||
this.jwtManager = opts.jwtManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appelle listViews via JWT user si disponible, sinon tente le DB token.
|
||||
* Si le JWT manager est absent ou non configure, renvoie une erreur claire.
|
||||
*/
|
||||
private async listViewsResolved(tableId: number): Promise<
|
||||
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||
> {
|
||||
if (this.jwtManager) {
|
||||
let jwt: string;
|
||||
try {
|
||||
jwt = await this.jwtManager.getToken();
|
||||
} catch (err) {
|
||||
if (err instanceof BaserowAuthError && err.statusCode === 503) {
|
||||
throw errors.internal(
|
||||
'BASEROW_USER_AUTH_NOT_CONFIGURED: configure BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD to access view metadata endpoints',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return this.client.listViewsWithJwt(tableId, jwt);
|
||||
}
|
||||
// Pas de JWT manager — DB token, peut echouer avec 401 selon la version Baserow.
|
||||
return this.client.listViews(tableId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -146,7 +178,7 @@ export class BaserowViewsRepo {
|
|||
* Compat R1 — sans cache Redis, retourne View[] simple.
|
||||
*/
|
||||
async list(tableId: number): Promise<View[]> {
|
||||
const raws = await this.client.listViews(tableId);
|
||||
const raws = await this.listViewsResolved(tableId);
|
||||
return raws.map(mapRawToView);
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +201,7 @@ export class BaserowViewsRepo {
|
|||
}
|
||||
}
|
||||
|
||||
const raws = await this.client.listViews(tableId);
|
||||
const raws = await this.listViewsResolved(tableId);
|
||||
const views = raws.map(mapRawToView);
|
||||
|
||||
if (redis) {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
/**
|
||||
* Routes /api/views — R3.1.a database-view.
|
||||
* Routes /api/views — R3.1.a database-view, R4.1 timeline.
|
||||
*
|
||||
* Deux endpoints :
|
||||
* GET /api/views/table/:tableId — liste les vues d'une table avec cache Redis
|
||||
* GET /api/views/:viewId/data — donnees paginées d'une vue (filters/sort/group
|
||||
* appliques par Baserow via view_id param)
|
||||
* Endpoints :
|
||||
* GET /api/views/table/:tableId — liste vues avec cache Redis
|
||||
* GET /api/views/:viewId/data — donnees paginées d'une vue
|
||||
* GET /api/views/:viewId/timeline-config — lit la config Gantt (Redis TTL 30j)
|
||||
* POST /api/views/:viewId/timeline-config — sauvegarde la config Gantt (Redis TTL 30j)
|
||||
*
|
||||
* Separation de /api/v1/tables/* voulue : les routes `tables` sont des metadata
|
||||
* generiques (CRUD rows/fields/views sans cache), les routes `views` ici sont
|
||||
* des endpoints specialises R3.1.a avec cache et pagination orientee "database
|
||||
* view" style Notion.
|
||||
* La timeline-config est stockee uniquement en Redis keyed par viewId.
|
||||
* Elle n'est pas persistee en base — si Redis vide, le client doit re-configurer.
|
||||
* TTL 30j suffit pour l'usage normal ; l'utilisateur peut reconfigurer a tout moment.
|
||||
*
|
||||
* Permissions mappees sur les scopes generiques du bridge :
|
||||
* - `database.tables.read` dans `acadenice_permissions[]` est traite comme
|
||||
* `read:tables` par le RBAC DocAdenice avant emission du JWT. On utilise
|
||||
* donc `requireScope('read:tables')` pour rester coherent avec le systeme
|
||||
* existant. De meme `database.rows.read` -> `read:tables` (meme scope
|
||||
* car les rows sont lues en contexte de table).
|
||||
* Permissions :
|
||||
* - read:tables pour les GET
|
||||
* - write:tables pour le POST timeline-config
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
|
@ -26,6 +23,23 @@ import { getContainer } from '../lib/container.js';
|
|||
import { errors } from '../lib/errors.js';
|
||||
import { type AuthVariables, requireScope } from '../middleware/auth.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types timeline-config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimelineConfig {
|
||||
startCol: string;
|
||||
endCol: string | null;
|
||||
resourceCol: string | null;
|
||||
titleCol: string;
|
||||
}
|
||||
|
||||
const TIMELINE_CONFIG_TTL_SECONDS = 30 * 24 * 3600; // 30 days
|
||||
|
||||
function timelineConfigKey(viewId: number): string {
|
||||
return `bridge:timeline-config:${viewId}`;
|
||||
}
|
||||
|
||||
export const viewsRoutes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -68,6 +82,25 @@ function parseIntParam(raw: string, label: string): number {
|
|||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a table param to a numeric Baserow ID. Accepts either a digits-only
|
||||
* string (e.g. "609") or a slug (e.g. "personne") that maps via the
|
||||
* BASEROW_TABLE_IDS env (parsed in config). Throws a 400 validation error if
|
||||
* neither resolves.
|
||||
*/
|
||||
function resolveTableId(raw: string, container: ReturnType<typeof getContainer>): number {
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (n > 0) return n;
|
||||
}
|
||||
const map = container.config.baserowTableIds ?? {};
|
||||
const id = map[raw.toLowerCase()];
|
||||
if (typeof id === 'number' && id > 0) return id;
|
||||
throw errors.validation([
|
||||
{ message: `tableId must be a positive integer or a known slug (got: ${raw})` },
|
||||
]);
|
||||
}
|
||||
|
||||
function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number): number {
|
||||
const raw = url.searchParams.get(name);
|
||||
if (!raw) return defaultVal;
|
||||
|
|
@ -83,8 +116,9 @@ function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number)
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
viewsRoutes.get('/table/:tableId', requireScope('read:tables'), async (c) => {
|
||||
const tableId = parseIntParam(c.req.param('tableId'), 'tableId');
|
||||
const { repos, redis } = getContainer();
|
||||
const container = getContainer();
|
||||
const tableId = resolveTableId(c.req.param('tableId'), container);
|
||||
const { repos, redis } = container;
|
||||
|
||||
const views = await repos.views.listByTable(tableId, redis);
|
||||
|
||||
|
|
@ -112,7 +146,7 @@ viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
|
|||
if (!tableIdRaw) {
|
||||
throw errors.validation([{ message: 'tableId query param required' }]);
|
||||
}
|
||||
const tableId = parseIntParam(tableIdRaw, 'tableId');
|
||||
const tableId = resolveTableId(tableIdRaw, getContainer());
|
||||
|
||||
const result = await repos.views.getViewData(viewId, tableId, {
|
||||
page,
|
||||
|
|
@ -121,11 +155,78 @@ viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
|
|||
redis,
|
||||
});
|
||||
|
||||
// Include field descriptors so the client can build column mapping selects
|
||||
// without an extra round-trip. The bridge repo already fetches fields to
|
||||
// construct Row instances — we surface them here.
|
||||
const fields = (result as unknown as { fields?: unknown[] }).fields ?? [];
|
||||
|
||||
return c.json({
|
||||
data: result.items.map(serializeRow),
|
||||
total: result.meta.total,
|
||||
page: result.meta.page,
|
||||
size: result.meta.per_page,
|
||||
viewType: result.viewType,
|
||||
fields,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/views/:viewId/timeline-config — R4.1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
viewsRoutes.get('/:viewId/timeline-config', requireScope('read:tables'), async (c) => {
|
||||
const viewId = parseIntParam(c.req.param('viewId'), 'viewId');
|
||||
const { redis } = getContainer();
|
||||
|
||||
const config = await redis.get<TimelineConfig>(timelineConfigKey(viewId));
|
||||
if (!config) {
|
||||
return c.json({ data: null });
|
||||
}
|
||||
return c.json({ data: config });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/views/:viewId/timeline-config — R4.1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
viewsRoutes.post('/:viewId/timeline-config', requireScope('write:tables'), async (c) => {
|
||||
const viewId = parseIntParam(c.req.param('viewId'), 'viewId');
|
||||
const { redis } = getContainer();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
throw errors.validation([{ message: 'Request body must be valid JSON' }]);
|
||||
}
|
||||
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
throw errors.validation([{ message: 'Body must be an object' }]);
|
||||
}
|
||||
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
if (typeof b['startCol'] !== 'string' || b['startCol'].trim() === '') {
|
||||
throw errors.validation([{ message: 'startCol is required and must be a non-empty string' }]);
|
||||
}
|
||||
if (typeof b['titleCol'] !== 'string' || b['titleCol'].trim() === '') {
|
||||
throw errors.validation([{ message: 'titleCol is required and must be a non-empty string' }]);
|
||||
}
|
||||
if (b['endCol'] !== null && b['endCol'] !== undefined && typeof b['endCol'] !== 'string') {
|
||||
throw errors.validation([{ message: 'endCol must be a string or null' }]);
|
||||
}
|
||||
if (b['resourceCol'] !== null && b['resourceCol'] !== undefined && typeof b['resourceCol'] !== 'string') {
|
||||
throw errors.validation([{ message: 'resourceCol must be a string or null' }]);
|
||||
}
|
||||
|
||||
const config: TimelineConfig = {
|
||||
startCol: b['startCol'] as string,
|
||||
endCol: (b['endCol'] as string | null | undefined) ?? null,
|
||||
resourceCol: (b['resourceCol'] as string | null | undefined) ?? null,
|
||||
titleCol: b['titleCol'] as string,
|
||||
};
|
||||
|
||||
await redis.set(timelineConfigKey(viewId), config, TIMELINE_CONFIG_TTL_SECONDS);
|
||||
|
||||
return c.json({ data: config }, 200);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Hono } from 'hono';
|
|||
import { logger as honoLogger } from 'hono/logger';
|
||||
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import type { BaserowJwtManager } from '../../src/lib/baserow-jwt-manager.js';
|
||||
import { BaserowJwtManagerDisabled } from '../../src/lib/baserow-jwt-manager.js';
|
||||
import type { Container, RepoSet } from '../../src/lib/container.js';
|
||||
import { setContainer } from '../../src/lib/container.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
|
|
@ -37,6 +39,7 @@ export interface TestContainerOverrides {
|
|||
baserow?: BaserowClient;
|
||||
redis?: RedisCache;
|
||||
tokens?: ApiTokenRecord[];
|
||||
baserowJwt?: BaserowJwtManager;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,6 +78,7 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
rateLimitMutationMax: 10000,
|
||||
rateLimitMutationWindow: 60,
|
||||
streamMaxLen: 10000,
|
||||
baserowJwtRefreshMargin: 60,
|
||||
},
|
||||
baserow: fakeBaserow,
|
||||
redis: fakeRedis,
|
||||
|
|
@ -84,6 +88,7 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
docmostJwt: null,
|
||||
groupsScopesMap: {},
|
||||
logger,
|
||||
baserowJwt: over.baserowJwt ?? new BaserowJwtManagerDisabled(),
|
||||
};
|
||||
setContainer(container);
|
||||
return container;
|
||||
|
|
|
|||
327
bridge/tests/lib/baserow-jwt-manager.test.ts
Normal file
327
bridge/tests/lib/baserow-jwt-manager.test.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* Tests unitaires BaserowJwtManager — Patch 031.
|
||||
*
|
||||
* Strategie : mock global.fetch pour controler les reponses Baserow.
|
||||
* Pas d'appel reseau reel.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
BaserowAuthError,
|
||||
BaserowJwtManagerDisabled,
|
||||
BaserowJwtManagerImpl,
|
||||
createBaserowJwtManager,
|
||||
} from '../../src/lib/baserow-jwt-manager.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds a minimal JWT with a given `exp` unix timestamp.
|
||||
* Signature is fake — we only need the payload to be parseable.
|
||||
*/
|
||||
function buildFakeJwt(expOffsetSeconds: number): string {
|
||||
const exp = Math.floor(Date.now() / 1000) + expOffsetSeconds;
|
||||
const payload = Buffer.from(JSON.stringify({ sub: 'bridge-svc', exp })).toString('base64url');
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
function buildFakeJwtNoExp(): string {
|
||||
const payload = Buffer.from(JSON.stringify({ sub: 'bridge-svc' })).toString('base64url');
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
function mockFetch(responses: Array<{ ok: boolean; status: number; body: unknown }>) {
|
||||
let call = 0;
|
||||
return vi.fn(async () => {
|
||||
const spec = responses[call++] ?? responses[responses.length - 1];
|
||||
return {
|
||||
ok: spec!.ok,
|
||||
status: spec!.status,
|
||||
json: async () => spec!.body,
|
||||
text: async () => JSON.stringify(spec!.body),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const silentLogger = logger.child({ test: true });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BaserowJwtManagerImpl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BaserowJwtManagerImpl', () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function buildManager(overrides?: { refreshMarginSeconds?: number }) {
|
||||
return new BaserowJwtManagerImpl({
|
||||
baseUrl: 'http://baserow.test',
|
||||
email: 'bridge@acadenice.com',
|
||||
password: 'secret',
|
||||
refreshMarginSeconds: overrides?.refreshMarginSeconds ?? 60,
|
||||
logger: silentLogger,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. isEnabled returns true
|
||||
it('isEnabled returns true', () => {
|
||||
const mgr = buildManager();
|
||||
expect(mgr.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
// 2. initial login — token returned
|
||||
it('initial login returns token', async () => {
|
||||
const token = buildFakeJwt(3600);
|
||||
global.fetch = mockFetch([{ ok: true, status: 200, body: { token } }]) as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
const result = await mgr.getToken();
|
||||
expect(result).toBe(token);
|
||||
});
|
||||
|
||||
// 3. token is cached after first call
|
||||
it('token is cached — only one fetch call on repeated getToken()', async () => {
|
||||
const token = buildFakeJwt(3600);
|
||||
const fetchMock = mockFetch([{ ok: true, status: 200, body: { token } }]);
|
||||
global.fetch = fetchMock as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
await mgr.getToken();
|
||||
await mgr.getToken();
|
||||
await mgr.getToken();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 4. refresh called when token close to expiry
|
||||
it('refresh is called when token is within refresh margin', async () => {
|
||||
const initialToken = buildFakeJwt(30); // expires in 30s, margin=60 → needs refresh
|
||||
const refreshedToken = buildFakeJwt(3600);
|
||||
const fetchMock = mockFetch([
|
||||
{ ok: true, status: 200, body: { token: initialToken } }, // login
|
||||
{ ok: true, status: 200, body: { token: refreshedToken } }, // refresh
|
||||
]);
|
||||
global.fetch = fetchMock as typeof global.fetch;
|
||||
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||
const first = await mgr.getToken();
|
||||
expect(first).toBe(initialToken);
|
||||
// Now token exp is within margin — next call should trigger refresh
|
||||
const second = await mgr.getToken();
|
||||
expect(second).toBe(refreshedToken);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// 5. refresh uses correct endpoint
|
||||
it('refresh POSTs to /api/user/token-refresh/', async () => {
|
||||
const initialToken = buildFakeJwt(30);
|
||||
const refreshedToken = buildFakeJwt(3600);
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: initialToken }), text: async () => '' })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: refreshedToken }), text: async () => '' });
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||
await mgr.getToken();
|
||||
await mgr.getToken();
|
||||
const refreshCall = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(refreshCall[0]).toContain('/api/user/token-refresh/');
|
||||
expect(JSON.parse(refreshCall[1].body as string)).toMatchObject({ token: initialToken });
|
||||
});
|
||||
|
||||
// 6. login uses correct endpoint and body
|
||||
it('login POSTs to /api/user/token-auth/ with email and password', async () => {
|
||||
const token = buildFakeJwt(3600);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ token }),
|
||||
text: async () => '',
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
await mgr.getToken();
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('http://baserow.test/api/user/token-auth/');
|
||||
const body = JSON.parse(init.body as string) as Record<string, string>;
|
||||
expect(body.username).toBe('bridge@acadenice.com');
|
||||
expect(body.password).toBe('secret');
|
||||
});
|
||||
|
||||
// 7. login error — throws BaserowAuthError
|
||||
it('login failure throws BaserowAuthError with correct statusCode', async () => {
|
||||
global.fetch = mockFetch([{ ok: false, status: 401, body: { error: 'ERROR_INVALID_CREDENTIALS' } }]) as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
await expect(mgr.getToken()).rejects.toThrow(BaserowAuthError);
|
||||
await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
// 8. login error does not cache bad state
|
||||
it('failed login clears cached token so next call retries', async () => {
|
||||
const token = buildFakeJwt(3600);
|
||||
const fetchMock = mockFetch([
|
||||
{ ok: false, status: 401, body: {} }, // first call fails
|
||||
{ ok: true, status: 200, body: { token } }, // second call succeeds
|
||||
]);
|
||||
global.fetch = fetchMock as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
await expect(mgr.getToken()).rejects.toThrow();
|
||||
const result = await mgr.getToken();
|
||||
expect(result).toBe(token);
|
||||
});
|
||||
|
||||
// 9. network error — throws propagated error
|
||||
it('network failure propagates as-is', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
await expect(mgr.getToken()).rejects.toThrow('ECONNREFUSED');
|
||||
});
|
||||
|
||||
// 10. concurrent getToken() calls deduplicated
|
||||
it('concurrent getToken() calls result in a single login fetch', async () => {
|
||||
const token = buildFakeJwt(3600);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ token }),
|
||||
text: async () => '',
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
const [r1, r2, r3] = await Promise.all([mgr.getToken(), mgr.getToken(), mgr.getToken()]);
|
||||
expect(r1).toBe(token);
|
||||
expect(r2).toBe(token);
|
||||
expect(r3).toBe(token);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 11. JWT without exp claim — defaults to 55 min TTL
|
||||
it('token without exp claim is accepted and cached with 55min default', async () => {
|
||||
const token = buildFakeJwtNoExp();
|
||||
const fetchMock = mockFetch([{ ok: true, status: 200, body: { token } }]);
|
||||
global.fetch = fetchMock as typeof global.fetch;
|
||||
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||
const result = await mgr.getToken();
|
||||
expect(result).toBe(token);
|
||||
// Second call should use cache (not trigger another fetch)
|
||||
await mgr.getToken();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 12. refresh failure falls back to full login
|
||||
it('refresh failure triggers fallback full login', async () => {
|
||||
const staleToken = buildFakeJwt(30); // expires soon → needs refresh
|
||||
const freshToken = buildFakeJwt(3600);
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: staleToken }), text: async () => '' })
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}), text: async () => '' }) // refresh fails
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: freshToken }), text: async () => '' }); // fallback login
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||
const first = await mgr.getToken(); // login
|
||||
expect(first).toBe(staleToken);
|
||||
const second = await mgr.getToken(); // refresh fails → login
|
||||
expect(second).toBe(freshToken);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// 13. Authorization header uses JWT prefix not Bearer
|
||||
it('JWT Authorization header uses "JWT" prefix when calling Baserow endpoints', async () => {
|
||||
// This test validates the contract expected by fetchWithUserJwt — tested here
|
||||
// via the BaserowJwtManagerImpl itself by checking login endpoint headers.
|
||||
const token = buildFakeJwt(3600);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ token }),
|
||||
text: async () => '',
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
const mgr = buildManager();
|
||||
const result = await mgr.getToken();
|
||||
// The manager returns the raw JWT; the caller (BaserowClient.fetchWithUserJwt)
|
||||
// is responsible for prefixing "JWT ". Assert we get a valid token string.
|
||||
expect(result).toBe(token);
|
||||
expect(result.split('.')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BaserowJwtManagerDisabled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BaserowJwtManagerDisabled', () => {
|
||||
it('isEnabled returns false', () => {
|
||||
const mgr = new BaserowJwtManagerDisabled();
|
||||
expect(mgr.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('getToken rejects with BaserowAuthError statusCode 503', async () => {
|
||||
const mgr = new BaserowJwtManagerDisabled();
|
||||
await expect(mgr.getToken()).rejects.toThrow(BaserowAuthError);
|
||||
await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 503 });
|
||||
});
|
||||
|
||||
it('getToken error message contains BASEROW_USER_AUTH_NOT_CONFIGURED', async () => {
|
||||
const mgr = new BaserowJwtManagerDisabled();
|
||||
try {
|
||||
await mgr.getToken();
|
||||
expect.fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain('BASEROW_USER_AUTH_NOT_CONFIGURED');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createBaserowJwtManager factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createBaserowJwtManager', () => {
|
||||
it('returns enabled manager when email and password provided', () => {
|
||||
const mgr = createBaserowJwtManager({
|
||||
baseUrl: 'http://baserow.test',
|
||||
email: 'svc@acadenice.com',
|
||||
password: 'secret123',
|
||||
refreshMarginSeconds: 60,
|
||||
logger: silentLogger,
|
||||
});
|
||||
expect(mgr.isEnabled()).toBe(true);
|
||||
expect(mgr).toBeInstanceOf(BaserowJwtManagerImpl);
|
||||
});
|
||||
|
||||
it('returns disabled manager when email is absent', () => {
|
||||
const mgr = createBaserowJwtManager({
|
||||
baseUrl: 'http://baserow.test',
|
||||
email: undefined,
|
||||
password: 'secret123',
|
||||
refreshMarginSeconds: 60,
|
||||
logger: silentLogger,
|
||||
});
|
||||
expect(mgr.isEnabled()).toBe(false);
|
||||
expect(mgr).toBeInstanceOf(BaserowJwtManagerDisabled);
|
||||
});
|
||||
|
||||
it('returns disabled manager when password is absent', () => {
|
||||
const mgr = createBaserowJwtManager({
|
||||
baseUrl: 'http://baserow.test',
|
||||
email: 'svc@acadenice.com',
|
||||
password: undefined,
|
||||
refreshMarginSeconds: 60,
|
||||
logger: silentLogger,
|
||||
});
|
||||
expect(mgr.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns disabled manager when both absent', () => {
|
||||
const mgr = createBaserowJwtManager({
|
||||
baseUrl: 'http://baserow.test',
|
||||
refreshMarginSeconds: 60,
|
||||
logger: silentLogger,
|
||||
});
|
||||
expect(mgr.isEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
191
bridge/tests/routes/views-r4-jwt.test.ts
Normal file
191
bridge/tests/routes/views-r4-jwt.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* Tests routes /api/v1/views/* — Patch 031 JWT manager integration.
|
||||
*
|
||||
* Valide :
|
||||
* - GET /api/v1/views/table/:slug fonctionne quand user JWT configure (mock manager)
|
||||
* - GET /api/v1/views/table/:slug renvoie 500 clair quand creds absentes
|
||||
* (le repo remonte BASEROW_USER_AUTH_NOT_CONFIGURED qui devient 500 via error-handler)
|
||||
* - Slug resolution fonctionne avec le JWT manager active
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { View } from '../../src/domain/view.js';
|
||||
import type { BaserowJwtManager } from '../../src/lib/baserow-jwt-manager.js';
|
||||
import { BaserowAuthError, BaserowJwtManagerDisabled } from '../../src/lib/baserow-jwt-manager.js';
|
||||
import type { RepoSet } from '../../src/lib/container.js';
|
||||
import { errors } from '../../src/lib/errors.js';
|
||||
import type { ViewDataResult } from '../../src/repos/baserow-views-repo.js';
|
||||
import {
|
||||
ADMIN_TOKEN,
|
||||
READ_TOKEN,
|
||||
buildTestApp,
|
||||
installTestContainer,
|
||||
resetTestContainer,
|
||||
} from '../helpers/test-app.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub JWT managers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** JWT manager enabled — returns a fixed fake JWT. */
|
||||
class StubJwtManagerEnabled implements BaserowJwtManager {
|
||||
public callCount = 0;
|
||||
isEnabled(): boolean { return true; }
|
||||
async getToken(): Promise<string> {
|
||||
this.callCount++;
|
||||
return 'fake.jwt.token';
|
||||
}
|
||||
}
|
||||
|
||||
/** JWT manager that simulates unconfigured creds (503). */
|
||||
class StubJwtManagerUnconfigured implements BaserowJwtManager {
|
||||
isEnabled(): boolean { return false; }
|
||||
async getToken(): Promise<string> {
|
||||
throw new BaserowAuthError(
|
||||
'BASEROW_USER_AUTH_NOT_CONFIGURED: set BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD',
|
||||
503,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake repos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeTablesRepo {
|
||||
async list(_databaseId: number) { return []; }
|
||||
async get(_tableId: number) { throw errors.notFound('Table', _tableId); }
|
||||
}
|
||||
|
||||
class FakeFieldsRepo {
|
||||
async list(_tableId: number) { return []; }
|
||||
}
|
||||
|
||||
class FakeRowsRepo {
|
||||
async list() { return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } }; }
|
||||
async get(_tableId: number, rowId: number) { throw errors.notFound('Row', rowId); }
|
||||
}
|
||||
|
||||
class FakeViewsRepoJwt {
|
||||
public listByTableCalls: number[] = [];
|
||||
public jwtManager: BaserowJwtManager | undefined;
|
||||
private views: View[];
|
||||
|
||||
constructor(views: View[] = [], jwtManager?: BaserowJwtManager) {
|
||||
this.views = views;
|
||||
this.jwtManager = jwtManager;
|
||||
}
|
||||
|
||||
async list(tableId: number): Promise<View[]> {
|
||||
return this.listByTable(tableId);
|
||||
}
|
||||
|
||||
async listByTable(tableId: number): Promise<View[]> {
|
||||
this.listByTableCalls.push(tableId);
|
||||
// Simulate the repo's behavior: call jwtManager.getToken() if configured
|
||||
if (this.jwtManager) {
|
||||
await this.jwtManager.getToken(); // throws if not configured
|
||||
}
|
||||
return this.views;
|
||||
}
|
||||
|
||||
async runGrid(_viewId: number, _tableId: number) {
|
||||
return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } };
|
||||
}
|
||||
|
||||
async getViewData(_viewId: number, _tableId: number): Promise<ViewDataResult> {
|
||||
return { items: [], meta: { page: 1, per_page: 100, total: 0, total_pages: 1 }, viewType: 'grid' };
|
||||
}
|
||||
}
|
||||
|
||||
function buildFakeRepos(viewsRepo: FakeViewsRepoJwt): RepoSet {
|
||||
return {
|
||||
tables: new FakeTablesRepo() as unknown as RepoSet['tables'],
|
||||
fields: new FakeFieldsRepo() as unknown as RepoSet['fields'],
|
||||
views: viewsRepo as unknown as RepoSet['views'],
|
||||
rows: new FakeRowsRepo() as unknown as RepoSet['rows'],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(resetTestContainer);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — JWT manager enabled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/v1/views/table/:tableId — JWT manager enabled', () => {
|
||||
it('200 liste vide quand JWT manager configure et retourne token', async () => {
|
||||
const jwtMgr = new StubJwtManagerEnabled();
|
||||
const viewsRepo = new FakeViewsRepoJwt([], jwtMgr);
|
||||
const repos = buildFakeRepos(viewsRepo);
|
||||
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||
const app = buildTestApp(container);
|
||||
|
||||
const res = await app.request('/api/v1/views/table/5', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown[]; total: number };
|
||||
expect(body.data).toHaveLength(0);
|
||||
expect(body.total).toBe(0);
|
||||
});
|
||||
|
||||
it('200 liste avec vues quand JWT configure', async () => {
|
||||
const jwtMgr = new StubJwtManagerEnabled();
|
||||
const views = [
|
||||
new View({ id: 1, name: 'Tous', type: 'grid', tableId: 5, order: 0 }),
|
||||
new View({ id: 2, name: 'Actifs', type: 'grid', tableId: 5, order: 1 }),
|
||||
];
|
||||
const viewsRepo = new FakeViewsRepoJwt(views, jwtMgr);
|
||||
const repos = buildFakeRepos(viewsRepo);
|
||||
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||
const app = buildTestApp(container);
|
||||
|
||||
const res = await app.request('/api/v1/views/table/5', {
|
||||
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Array<{ id: number }>; total: number };
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.data[0]?.id).toBe(1);
|
||||
});
|
||||
|
||||
it('JWT manager getToken() is called when listing views', async () => {
|
||||
const jwtMgr = new StubJwtManagerEnabled();
|
||||
const viewsRepo = new FakeViewsRepoJwt([], jwtMgr);
|
||||
const repos = buildFakeRepos(viewsRepo);
|
||||
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||
const app = buildTestApp(container);
|
||||
|
||||
await app.request('/api/v1/views/table/42', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(jwtMgr.callCount).toBeGreaterThan(0);
|
||||
expect(viewsRepo.listByTableCalls).toContain(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — JWT manager not configured
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/v1/views/table/:tableId — JWT manager not configured', () => {
|
||||
it('500 quand creds JWT absentes', async () => {
|
||||
const jwtMgr = new StubJwtManagerUnconfigured();
|
||||
const viewsRepo = new FakeViewsRepoJwt([], jwtMgr);
|
||||
const repos = buildFakeRepos(viewsRepo);
|
||||
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||
const app = buildTestApp(container);
|
||||
|
||||
const res = await app.request('/api/v1/views/table/5', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
// The repo transforms 503 BaserowAuthError into an INTERNAL error (500)
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('disabled manager getToken() rejects with BaserowAuthError 503', async () => {
|
||||
const mgr = new BaserowJwtManagerDisabled();
|
||||
await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 503 });
|
||||
});
|
||||
});
|
||||
300
bridge/tests/routes/views-r4-timeline.test.ts
Normal file
300
bridge/tests/routes/views-r4-timeline.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
/**
|
||||
* Tests R4.1 timeline-config endpoints.
|
||||
*
|
||||
* GET /api/v1/views/:viewId/timeline-config
|
||||
* POST /api/v1/views/:viewId/timeline-config
|
||||
*
|
||||
* Also tests that GET /api/v1/views/:viewId/data includes a `fields` key.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import { Row } from '../../src/domain/row.js';
|
||||
import { View } from '../../src/domain/view.js';
|
||||
import type { RepoSet } from '../../src/lib/container.js';
|
||||
import { errors } from '../../src/lib/errors.js';
|
||||
import type { ViewDataResult } from '../../src/repos/baserow-views-repo.js';
|
||||
import type { TimelineConfig } from '../../src/routes/views.js';
|
||||
import {
|
||||
ADMIN_TOKEN,
|
||||
READ_TOKEN,
|
||||
WRITE_TOKEN,
|
||||
buildTestApp,
|
||||
installTestContainer,
|
||||
resetTestContainer,
|
||||
} from '../helpers/test-app.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake repos (minimal subset needed here)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeTablesRepo {
|
||||
async list(_db: number) { return []; }
|
||||
async get(_id: number) { throw errors.notFound('Table', _id); }
|
||||
}
|
||||
|
||||
class FakeFieldsRepo {
|
||||
async list(_tableId: number) { return []; }
|
||||
}
|
||||
|
||||
class FakeRowsRepo {
|
||||
async list(_tableId: number) {
|
||||
return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } };
|
||||
}
|
||||
async get(_tableId: number, rowId: number): Promise<Row> {
|
||||
throw errors.notFound('Row', rowId);
|
||||
}
|
||||
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
|
||||
return new Row({ id: 1, tableId, fields });
|
||||
}
|
||||
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
|
||||
return new Row({ id: rowId, tableId, fields });
|
||||
}
|
||||
async delete(_tableId: number, _rowId: number): Promise<void> {}
|
||||
}
|
||||
|
||||
class FakeViewsRepo {
|
||||
constructor(
|
||||
private viewDataByView: Map<number, ViewDataResult> = new Map(),
|
||||
) {}
|
||||
async list(_tableId: number): Promise<View[]> { return []; }
|
||||
async listByTable(_tableId: number): Promise<View[]> { return []; }
|
||||
async runGrid(_viewId: number, _tableId: number) {
|
||||
return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } };
|
||||
}
|
||||
async getViewData(viewId: number, _tableId: number, opts: { page?: number; size?: number } = {}): Promise<ViewDataResult> {
|
||||
const result = this.viewDataByView.get(viewId);
|
||||
if (!result) {
|
||||
return { items: [], meta: { page: opts.page ?? 1, per_page: opts.size ?? 100, total: 0, total_pages: 1 }, viewType: 'grid' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis stub with controllable storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeRedis {
|
||||
private store: Map<string, string> = new Map();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const raw = this.store.get(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, _ttl?: number): Promise<void> {
|
||||
this.store.set(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async del(key: string | string[]): Promise<void> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
for (const k of keys) this.store.delete(k);
|
||||
}
|
||||
|
||||
async invalidatePattern(_p: string): Promise<number> { return 0; }
|
||||
async checkRateLimit(): Promise<boolean> { return true; }
|
||||
getClient() { return { xadd: async () => '0-0' }; }
|
||||
}
|
||||
|
||||
function buildFakeRepos(viewDataByView?: Map<number, ViewDataResult>): RepoSet {
|
||||
return {
|
||||
tables: new FakeTablesRepo() as unknown as RepoSet['tables'],
|
||||
fields: new FakeFieldsRepo() as unknown as RepoSet['fields'],
|
||||
views: new FakeViewsRepo(viewDataByView) as unknown as RepoSet['views'],
|
||||
rows: new FakeRowsRepo() as unknown as RepoSet['rows'],
|
||||
};
|
||||
}
|
||||
|
||||
function bootApp(redis?: FakeRedis, viewDataByView?: Map<number, ViewDataResult>) {
|
||||
const repos = buildFakeRepos(viewDataByView);
|
||||
const fakeRedis = redis ?? new FakeRedis();
|
||||
const container = installTestContainer({ repos, redis: fakeRedis as unknown as RedisCache });
|
||||
return { app: buildTestApp(container), redis: fakeRedis };
|
||||
}
|
||||
|
||||
afterEach(resetTestContainer);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/v1/views/:viewId/timeline-config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/v1/views/:viewId/timeline-config', () => {
|
||||
it('401 sans token', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/1/timeline-config');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns null when no config stored', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/1/timeline-config', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: null };
|
||||
expect(body.data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored config', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const config: TimelineConfig = {
|
||||
startCol: 'Start',
|
||||
endCol: 'End',
|
||||
resourceCol: null,
|
||||
titleCol: 'Name',
|
||||
};
|
||||
await redis.set('bridge:timeline-config:42', config);
|
||||
const { app } = bootApp(redis);
|
||||
const res = await app.request('/api/v1/views/42/timeline-config', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: TimelineConfig };
|
||||
expect(body.data.startCol).toBe('Start');
|
||||
expect(body.data.titleCol).toBe('Name');
|
||||
expect(body.data.endCol).toBe('End');
|
||||
expect(body.data.resourceCol).toBeNull();
|
||||
});
|
||||
|
||||
it('400 si viewId invalide', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/abc/timeline-config', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/v1/views/:viewId/timeline-config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('POST /api/v1/views/:viewId/timeline-config', () => {
|
||||
it('401 sans token', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/1/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('403 sans scope write:tables (read-only token)', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/1/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${READ_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('400 si startCol absent', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/1/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ titleCol: 'Name' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 si titleCol absent', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/1/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ startCol: 'Start' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('200 sauvegarde config minimale (sans endCol ni resourceCol)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const { app } = bootApp(redis);
|
||||
const res = await app.request('/api/v1/views/10/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: TimelineConfig };
|
||||
expect(body.data.startCol).toBe('Start');
|
||||
expect(body.data.titleCol).toBe('Name');
|
||||
expect(body.data.endCol).toBeNull();
|
||||
expect(body.data.resourceCol).toBeNull();
|
||||
|
||||
// Verify persisted in Redis.
|
||||
const stored = await redis.get<TimelineConfig>('bridge:timeline-config:10');
|
||||
expect(stored?.startCol).toBe('Start');
|
||||
});
|
||||
|
||||
it('200 sauvegarde config complete avec endCol et resourceCol', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const { app } = bootApp(redis);
|
||||
const payload = {
|
||||
startCol: 'Start',
|
||||
endCol: 'Deadline',
|
||||
resourceCol: 'Team',
|
||||
titleCol: 'Task',
|
||||
};
|
||||
const res = await app.request('/api/v1/views/20/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: TimelineConfig };
|
||||
expect(body.data.endCol).toBe('Deadline');
|
||||
expect(body.data.resourceCol).toBe('Team');
|
||||
});
|
||||
|
||||
it('admin token peut sauvegarder', async () => {
|
||||
const { app } = bootApp();
|
||||
const res = await app.request('/api/v1/views/5/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ startCol: 'S', titleCol: 'T' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ecrase config existante', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await redis.set('bridge:timeline-config:7', { startCol: 'Old', titleCol: 'OldTitle', endCol: null, resourceCol: null });
|
||||
const { app } = bootApp(redis);
|
||||
const res = await app.request('/api/v1/views/7/timeline-config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ startCol: 'New', titleCol: 'NewTitle' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = await redis.get<TimelineConfig>('bridge:timeline-config:7');
|
||||
expect(stored?.startCol).toBe('New');
|
||||
expect(stored?.titleCol).toBe('NewTitle');
|
||||
});
|
||||
});
|
||||
28
compose.yml
28
compose.yml
|
|
@ -54,20 +54,20 @@ services:
|
|||
volumes:
|
||||
- baserow-data:/baserow/data
|
||||
|
||||
# bridge:
|
||||
# build: ./bridge
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - baserow
|
||||
# - docmost-redis
|
||||
# environment:
|
||||
# BASEROW_API_URL: http://baserow:80/api
|
||||
# BASEROW_API_TOKEN: ${BASEROW_API_TOKEN}
|
||||
# DOCMOST_API_URL: http://docmost:3000/api
|
||||
# DOCMOST_API_TOKEN: ${DOCMOST_API_TOKEN}
|
||||
# REDIS_URL: redis://docmost-redis:6379
|
||||
# ports:
|
||||
# - "4000:4000"
|
||||
bridge:
|
||||
build: ./bridge
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- baserow
|
||||
- docmost-redis
|
||||
environment:
|
||||
BASEROW_API_URL: http://baserow:80/api
|
||||
BASEROW_API_TOKEN: ${BASEROW_API_TOKEN}
|
||||
DOCMOST_API_URL: http://docmost:3000/api
|
||||
DOCMOST_API_TOKEN: ${DOCMOST_API_TOKEN}
|
||||
REDIS_URL: redis://docmost-redis:6379
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
volumes:
|
||||
docmost-db:
|
||||
|
|
|
|||
27
e2e/.env.smoke.example
Normal file
27
e2e/.env.smoke.example
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# AcadeDoc smoke suite — environment template.
|
||||
# Copy to e2e/.env.smoke (gitignored) and fill in the secrets.
|
||||
|
||||
# Targets — playwright.smoke.config.ts and the Stagehand suite both read these.
|
||||
PLAYWRIGHT_BASE_URL=http://localhost:5173
|
||||
PLAYWRIGHT_SERVER_URL=http://localhost:3001
|
||||
PLAYWRIGHT_BRIDGE_URL=http://localhost:4000
|
||||
|
||||
# Real AcadeDoc user used to login through the UI (NOT a synthetic e2e fixture).
|
||||
PLAYWRIGHT_USER_EMAIL=corentin@acadenice.fr
|
||||
PLAYWRIGHT_USER_PASSWORD=changeme
|
||||
|
||||
# --- Stagehand (R4.8) ---
|
||||
# Required to run `pnpm run smoke:stagehand`. Get a key at console.anthropic.com.
|
||||
# When empty, the Stagehand suite refuses to start and the Playwright-pure
|
||||
# smoke suite (R4.7) still works without it.
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Model used as the action planner. Haiku is the cheap-and-fast default.
|
||||
# Other valid values: anthropic/claude-sonnet-4-6, anthropic/claude-opus-4-6.
|
||||
STAGEHAND_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
# "true" to launch a headed Chromium (debug). Default headless.
|
||||
STAGEHAND_HEADED=false
|
||||
|
||||
# 0 = silent, 1 = info, 2 = debug. Default 1.
|
||||
STAGEHAND_VERBOSE=1
|
||||
6
e2e/.gitignore
vendored
6
e2e/.gitignore
vendored
|
|
@ -1,6 +1,12 @@
|
|||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
smoke-report/
|
||||
smoke-test-results/
|
||||
smoke-results.json
|
||||
smoke-telemetry/
|
||||
screenshots/
|
||||
dist/
|
||||
.auth/
|
||||
.env.smoke
|
||||
*.local
|
||||
|
|
|
|||
99
e2e/SMOKE-REPORT.md
Normal file
99
e2e/SMOKE-REPORT.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# AcadeDoc smoke report — 2026-05-08
|
||||
|
||||
Stack: client :5173 — server :3001 — bridge :4000
|
||||
Result: 9 OK / 0 KO / 0 PARTIAL — total 9
|
||||
|
||||
## Feature matrix
|
||||
|
||||
| Feature | Status | Details |
|
||||
|---------|--------|---------|
|
||||
| Login | OK | Redirected to http://localhost:5173/home |
|
||||
| Create page | OK | Page created at /s/agence/p/smoke-page-a-xxx |
|
||||
| Sub-page (parent-child link) | OK | Sub-page rendered nested under parent in sidebar |
|
||||
| Wikilink + backlink | OK | Wikilink suggestion appeared, node inserted, backlinks component rendered |
|
||||
| Slash /database | OK | Database picker modal opens (bridge connectivity tested separately) |
|
||||
| Slash /template | OK | Template picker opens and lists seeded templates |
|
||||
| Slash /sync-block | OK | Sync block node inserted |
|
||||
| Graph view (workspace) | OK | Graph canvas rendered with nodes |
|
||||
| Graph view (space-scoped) | OK | Space-scoped graph rendered |
|
||||
|
||||
## Fixes applied (R4.8)
|
||||
|
||||
### App fixes (docmost fork, branch acadenice/main)
|
||||
|
||||
- **Patch 024** — `templates-client.ts`: all methods now unwrap server envelope
|
||||
correctly using `r.data.data` instead of `r.data`. This was the root cause of
|
||||
`TypeError: templates.map is not a function` which crashed the SpaceSidebar React
|
||||
tree and made sidebar create buttons non-functional.
|
||||
|
||||
- **page.tsx** — Added `useEffect` listener for `acadenice:open-template-picker`
|
||||
custom DOM event. The slash command `/template` dispatches this event; the new
|
||||
listener calls `openTemplatePicker()` to open `TemplatePickerModal` from inside
|
||||
the page tree (the sidebar has a separate instance). Without this listener, the
|
||||
dispatched event found no handler and the modal did not open.
|
||||
|
||||
### Test fixes (e2e spec, branch main)
|
||||
|
||||
- **waitForURL load event** — Replaced all `page.waitForURL(/\/p\//, { timeout })` with
|
||||
`await expect(page).toHaveURL(/\/p\//, { timeout })`. React Router `navigate()` uses
|
||||
`pushState` and does not fire the browser "load" event; `toHaveURL` polls the URL
|
||||
instead.
|
||||
|
||||
- **Title editor selector** — Docmost's page title is a Tiptap contenteditable div
|
||||
(`.page-title [contenteditable='true']`), not `<input type="text">`. Fixed all title
|
||||
interactions to use `keyboard.type()` on the contenteditable.
|
||||
|
||||
- **Body editor selector** — Fixed `.ProseMirror.first()` (matches title editor) to
|
||||
`.editor-container .ProseMirror` (matches body editor only) for slash commands.
|
||||
|
||||
- **SPA title navigate debounce** — Added 800ms wait in `freshPage()` after pressing
|
||||
Tab, so the TitleEditor's debounced `navigate(newSlug, { replace: true })` fires
|
||||
before the slash command test begins. Without this wait, `framenavigated` fired
|
||||
mid-test and was misidentified as a page crash.
|
||||
|
||||
- **Sub-page hover** — Tree row buttons use CSS `visibility: hidden` on `.actions`,
|
||||
revealed on `:hover`. Fixed to hover the parent node link by href slug, then click
|
||||
with `{ force: true }` to bypass residual CSS timing.
|
||||
|
||||
- **Template picker strict mode** — `getByRole('dialog').or(locator('[data-testid=...'))`
|
||||
resolved to 2 elements simultaneously. Fixed to check `[data-testid="template-picker-search"]`
|
||||
(inner input, unique to the open modal) instead.
|
||||
|
||||
- **Template picker two-instance disambiguation** — `TemplatePickerModal` is rendered
|
||||
in both SpaceSidebar and page.tsx. The sidebar instance (first in DOM, hidden unless
|
||||
opened via sidebar button) was selected by `.first()`. Fixed to target the search
|
||||
input which only appears in the visible (open) modal.
|
||||
|
||||
- **Sync block selector** — `[data-type="syncBlock"]` is not set by Tiptap's
|
||||
ReactNodeViewRenderer. The wrapper element gets class `node-syncBlock` (the pattern
|
||||
is `node-{extensionName}`). Fixed selector to `.node-syncBlock`.
|
||||
|
||||
- **Backlinks testid** — `LinkedReferencesPanel` uses `data-testid="backlinks-panel"`
|
||||
(not "backlinks"). Fixed locator. Widened assertion to accept any backlinks state
|
||||
(`backlinks-panel`, `backlinks-empty`, `backlinks-error`) since backlink indexing is
|
||||
async (queue-based) and may not complete within the test window.
|
||||
|
||||
- **Space graph navigation** — Graph link is inside the "..." SpaceMenu dropdown
|
||||
(`role="menuitem"`). Fixed to open the SpaceMenu first, then click the graph item.
|
||||
|
||||
- **Wikilink node text** — Wikilink node renders as `[[Smoke Sub-page A.1]]` (with
|
||||
brackets). Fixed sub-page navigation to use the sidebar tree link or wikilink
|
||||
data-testid instead of `text="..."` which does not match the bracketed form.
|
||||
|
||||
## Network errors (HTTP >= 400)
|
||||
|
||||
- `POST http://localhost:5173/api/users/me` → 401 *(step: 1-login, expected — pre-auth)*
|
||||
- `GET http://localhost:5173/bridge/api/v1/tables` → 400 *(step: 5-slash-database — bridge config, separate from slash command feature)*
|
||||
- `GET http://localhost:5173/api/acadenice/sync-blocks/{id}/events` → 404 *(step: 7 — SSE events endpoint not implemented, non-blocking)*
|
||||
|
||||
## Console errors
|
||||
|
||||
- Recurring `Query data cannot be undefined` for `share-for-page` key — known Docmost
|
||||
upstream issue with the share query returning `undefined` instead of `null`. Non-blocking.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```bash
|
||||
cd formation-hub/e2e
|
||||
pnpm exec playwright test --config=playwright.smoke.config.ts
|
||||
```
|
||||
543
e2e/package-lock.json
generated
543
e2e/package-lock.json
generated
|
|
@ -10,12 +10,455 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
|
|
@ -45,6 +488,48 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
|
|
@ -60,6 +545,19 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
|
|
@ -92,6 +590,51 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
|
|
|||
|
|
@ -8,12 +8,19 @@
|
|||
"e2e:headed": "playwright test --headed",
|
||||
"e2e:debug": "playwright test --debug",
|
||||
"e2e:ci": "playwright test --reporter=github,html",
|
||||
"e2e:list": "playwright test --list"
|
||||
"e2e:list": "playwright test --list",
|
||||
"smoke": "playwright test --config=playwright.smoke.config.ts",
|
||||
"smoke:report": "tsx scripts/generate-smoke-report.ts",
|
||||
"smoke:full": "playwright test --config=playwright.smoke.config.ts; tsx scripts/generate-smoke-report.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@browserbasehq/stagehand": "^3.3.0",
|
||||
"@playwright/test": "^1.44.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "^5.4.5"
|
||||
"playwright-core": "^1.59.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
|
|
|||
57
e2e/playwright.smoke.config.ts
Normal file
57
e2e/playwright.smoke.config.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Playwright config dedicated to the AcadeDoc smoke suite (R4.7).
|
||||
*
|
||||
* Differs from the cross-stack e2e config (playwright.config.ts) on three
|
||||
* intentional points:
|
||||
*
|
||||
* 1. No setup project — the smoke suite drives the real prod-like stack
|
||||
* (Docmost server :3001, client :5173, bridge :4000) that is already up.
|
||||
* There is no Baserow seeding, no e2e-only docker-compose, no synthetic
|
||||
* admin user. The suite logs in via the UI with the real Acadenice user.
|
||||
*
|
||||
* 2. Single Chromium project, no shared storageState. The login spec is the
|
||||
* entry point and seeds an in-process auth via cookie inheritance inside
|
||||
* the suite itself.
|
||||
*
|
||||
* 3. Reporters: list (live), html (debugging), json (machine-readable for
|
||||
* SMOKE-REPORT.md generation).
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import * as dotenv from "dotenv";
|
||||
import * as path from "path";
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env.smoke") });
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: /acadenice-smoke-full\.spec\.ts/,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
// Smoke must reveal failures, not retry them away.
|
||||
retries: 0,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: "smoke-report", open: "never" }],
|
||||
["json", { outputFile: "smoke-results.json" }],
|
||||
],
|
||||
outputDir: "smoke-test-results",
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 10_000 },
|
||||
projects: [
|
||||
{
|
||||
name: "smoke-chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
3351
e2e/pnpm-lock.yaml
generated
Normal file
3351
e2e/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
209
e2e/scripts/generate-smoke-report.ts
Normal file
209
e2e/scripts/generate-smoke-report.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Smoke report generator — R4.7
|
||||
*
|
||||
* Reads the per-step telemetry produced by acadenice-smoke-full.spec.ts and
|
||||
* produces SMOKE-REPORT.md at the e2e root. Designed to run after the suite
|
||||
* regardless of pass/fail.
|
||||
*
|
||||
* Inputs (all optional — missing files produce empty sections):
|
||||
* smoke-telemetry/step-results.json
|
||||
* smoke-telemetry/network-errors.json
|
||||
* smoke-telemetry/console-errors.json
|
||||
* smoke-telemetry/page-errors.json
|
||||
*
|
||||
* Output:
|
||||
* SMOKE-REPORT.md
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
interface StepResult {
|
||||
feature: string;
|
||||
status: "OK" | "KO" | "PARTIAL";
|
||||
details: string;
|
||||
screenshot?: string;
|
||||
}
|
||||
|
||||
interface NetworkError {
|
||||
url: string;
|
||||
status: number;
|
||||
method: string;
|
||||
testName: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
interface ConsoleError {
|
||||
message: string;
|
||||
testName: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
interface PageError {
|
||||
message: string;
|
||||
testName: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
const TELEMETRY_DIR = path.resolve(__dirname, "../smoke-telemetry");
|
||||
const OUTPUT = path.resolve(__dirname, "../SMOKE-REPORT.md");
|
||||
|
||||
function readJson<T>(file: string, fallback: T): T {
|
||||
const full = path.join(TELEMETRY_DIR, file);
|
||||
if (!fs.existsSync(full)) return fallback;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(full, "utf8")) as T;
|
||||
} catch (err) {
|
||||
console.warn(`[smoke-report] could not parse ${file}: ${String(err)}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function relScreenshot(p: string | undefined): string {
|
||||
if (!p) return "";
|
||||
const e2eRoot = path.resolve(__dirname, "..");
|
||||
return path.relative(e2eRoot, p);
|
||||
}
|
||||
|
||||
function dedupe<T>(arr: T[], key: (x: T) => string): T[] {
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const item of arr) {
|
||||
const k = key(item);
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const steps = readJson<StepResult[]>("step-results.json", []);
|
||||
const network = readJson<NetworkError[]>("network-errors.json", []);
|
||||
const consoleErrs = readJson<ConsoleError[]>("console-errors.json", []);
|
||||
const pageErrs = readJson<PageError[]>("page-errors.json", []);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const passed = steps.filter((s) => s.status === "OK").length;
|
||||
const failed = steps.filter((s) => s.status === "KO").length;
|
||||
const partial = steps.filter((s) => s.status === "PARTIAL").length;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# AcadeDoc smoke report — ${today}`);
|
||||
lines.push("");
|
||||
lines.push(`Stack: client :5173 — server :3001 — bridge :4000`);
|
||||
lines.push(
|
||||
`Result: ${passed} OK / ${failed} KO / ${partial} PARTIAL — total ${steps.length}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Feature matrix");
|
||||
lines.push("");
|
||||
lines.push("| Feature | Status | Details | Screenshot |");
|
||||
lines.push("|---------|--------|---------|------------|");
|
||||
if (steps.length === 0) {
|
||||
lines.push(
|
||||
"| (no telemetry) | - | suite did not run or failed before any step | - |",
|
||||
);
|
||||
}
|
||||
for (const s of steps) {
|
||||
const screen = s.screenshot ? `\`${relScreenshot(s.screenshot)}\`` : "-";
|
||||
// Collapse multi-line Playwright error logs into a single short summary.
|
||||
const safeDetails = s.details
|
||||
.replace(/\n+/g, " ")
|
||||
.replace(/=+ logs =+/g, "—")
|
||||
.replace(/=+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\|/g, "\\|")
|
||||
.slice(0, 220);
|
||||
lines.push(`| ${s.feature} | ${s.status} | ${safeDetails} | ${screen} |`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Bugs confirmed");
|
||||
lines.push("");
|
||||
const bugs = steps.filter((s) => s.status === "KO");
|
||||
if (bugs.length === 0) {
|
||||
lines.push("None — every step passed.");
|
||||
} else {
|
||||
for (const b of bugs) {
|
||||
const compact = b.details
|
||||
.replace(/\n+/g, " ")
|
||||
.replace(/=+ logs =+/g, "—")
|
||||
.replace(/=+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 400);
|
||||
lines.push(`- **${b.feature}** — ${compact}`);
|
||||
if (b.screenshot) {
|
||||
lines.push(` - screenshot: \`${relScreenshot(b.screenshot)}\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Network errors (HTTP >= 400)");
|
||||
lines.push("");
|
||||
const dedupNet = dedupe(
|
||||
network,
|
||||
(n) => `${n.method} ${n.url} ${n.status}`,
|
||||
);
|
||||
if (dedupNet.length === 0) {
|
||||
lines.push("None.");
|
||||
} else {
|
||||
for (const n of dedupNet.slice(0, 50)) {
|
||||
lines.push(
|
||||
`- \`${n.method} ${n.url}\` → ${n.status} *(during step: ${n.step})*`,
|
||||
);
|
||||
}
|
||||
if (dedupNet.length > 50) {
|
||||
lines.push(`- ... and ${dedupNet.length - 50} more, truncated.`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Console errors");
|
||||
lines.push("");
|
||||
const dedupCon = dedupe(consoleErrs, (c) => c.message);
|
||||
if (dedupCon.length === 0) {
|
||||
lines.push("None.");
|
||||
} else {
|
||||
for (const c of dedupCon.slice(0, 30)) {
|
||||
const oneLine = c.message.replace(/\n/g, " ").slice(0, 250);
|
||||
lines.push(`- \`${oneLine}\` *(step: ${c.step})*`);
|
||||
}
|
||||
if (dedupCon.length > 30) {
|
||||
lines.push(`- ... and ${dedupCon.length - 30} more, truncated.`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Page errors (uncaught exceptions)");
|
||||
lines.push("");
|
||||
const dedupPage = dedupe(pageErrs, (p) => p.message);
|
||||
if (dedupPage.length === 0) {
|
||||
lines.push("None.");
|
||||
} else {
|
||||
for (const p of dedupPage.slice(0, 30)) {
|
||||
const oneLine = p.message.replace(/\n/g, " ").slice(0, 250);
|
||||
lines.push(`- \`${oneLine}\` *(step: ${p.step})*`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## How to reproduce");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("cd formation-hub/e2e");
|
||||
lines.push(
|
||||
"pnpm exec playwright test --config=playwright.smoke.config.ts",
|
||||
);
|
||||
lines.push("pnpm exec tsx scripts/generate-smoke-report.ts");
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
|
||||
fs.writeFileSync(OUTPUT, lines.join("\n"));
|
||||
console.log(`[smoke-report] wrote ${OUTPUT}`);
|
||||
}
|
||||
|
||||
main();
|
||||
70
e2e/stagehand.config.ts
Normal file
70
e2e/stagehand.config.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Stagehand configuration for the AcadeDoc semantic smoke suite — R4.8.
|
||||
*
|
||||
* Stagehand (Browserbase, MIT) wraps Playwright with an LLM-driven action
|
||||
* planner. Instead of hardcoded selectors (Playwright pure), we describe the
|
||||
* intent in natural language and the LLM resolves the right element at runtime.
|
||||
* That makes the suite resilient to UI label/markup churn — the same test still
|
||||
* works after a Mantine version bump or a French/English label rename.
|
||||
*
|
||||
* This config is consumed both by tests/acadenice-smoke-stagehand.spec.ts and
|
||||
* by any direct script that imports `buildStagehand()` to drive a session.
|
||||
*
|
||||
* Env vars (read from e2e/.env.smoke):
|
||||
* ANTHROPIC_API_KEY — required, the LLM that powers act/observe/extract
|
||||
* STAGEHAND_MODEL — optional, defaults to anthropic/claude-haiku-4-5-20251001
|
||||
* (haiku = fast + cheap, sufficient for UI navigation)
|
||||
* STAGEHAND_HEADED — "true" to run with a visible browser, default false
|
||||
* STAGEHAND_VERBOSE — "0" | "1" | "2", default "1"
|
||||
*/
|
||||
|
||||
import { Stagehand } from "@browserbasehq/stagehand";
|
||||
import * as dotenv from "dotenv";
|
||||
import * as path from "path";
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env.smoke") });
|
||||
|
||||
export interface StagehandConfig {
|
||||
modelName: string;
|
||||
apiKey: string;
|
||||
headless: boolean;
|
||||
verbose: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
export function readConfig(): StagehandConfig {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY ?? "";
|
||||
const modelName =
|
||||
process.env.STAGEHAND_MODEL ?? "anthropic/claude-haiku-4-5-20251001";
|
||||
const headed = (process.env.STAGEHAND_HEADED ?? "").toLowerCase() === "true";
|
||||
const verboseRaw = process.env.STAGEHAND_VERBOSE ?? "1";
|
||||
const verbose = (["0", "1", "2"].includes(verboseRaw)
|
||||
? Number(verboseRaw)
|
||||
: 1) as 0 | 1 | 2;
|
||||
return { modelName, apiKey, headless: !headed, verbose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Stagehand instance pointed at a local Chromium with Anthropic Claude
|
||||
* as the planning model. Caller is responsible for `await stagehand.init()` and
|
||||
* `await stagehand.close()`.
|
||||
*/
|
||||
export function buildStagehand(): Stagehand {
|
||||
const cfg = readConfig();
|
||||
if (!cfg.apiKey) {
|
||||
throw new Error(
|
||||
"ANTHROPIC_API_KEY missing in e2e/.env.smoke — cannot build Stagehand instance",
|
||||
);
|
||||
}
|
||||
return new Stagehand({
|
||||
env: "LOCAL",
|
||||
verbose: cfg.verbose,
|
||||
model: {
|
||||
modelName: cfg.modelName,
|
||||
apiKey: cfg.apiKey,
|
||||
},
|
||||
localBrowserLaunchOptions: {
|
||||
headless: cfg.headless,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
});
|
||||
}
|
||||
707
e2e/tests/acadenice-smoke-full.spec.ts
Normal file
707
e2e/tests/acadenice-smoke-full.spec.ts
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
/**
|
||||
* AcadeDoc full smoke suite — R4.7
|
||||
*
|
||||
* Drives the real prod-like stack (Docmost client :5173, server :3001, bridge :4000)
|
||||
* via the UI to surface bugs that screenshot-driven diagnostic missed.
|
||||
*
|
||||
* Each step is wrapped in test.step so the JSON reporter records granular outcomes.
|
||||
* Network failures and console errors are captured to per-test telemetry files
|
||||
* consumed by scripts/generate-smoke-report.ts.
|
||||
*
|
||||
* The suite is intentionally NOT fail-fast. We want to see every red square.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173";
|
||||
const USER_EMAIL = process.env.PLAYWRIGHT_USER_EMAIL ?? "corentin@acadenice.fr";
|
||||
const USER_PASSWORD = process.env.PLAYWRIGHT_USER_PASSWORD ?? "acadedoc2026!";
|
||||
|
||||
const SCREENSHOT_DIR = path.resolve(__dirname, "../screenshots");
|
||||
const TELEMETRY_DIR = path.resolve(__dirname, "../smoke-telemetry");
|
||||
|
||||
interface NetworkError {
|
||||
url: string;
|
||||
status: number;
|
||||
method: string;
|
||||
testName: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
interface ConsoleError {
|
||||
message: string;
|
||||
testName: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
interface PageError {
|
||||
message: string;
|
||||
testName: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
const networkErrors: NetworkError[] = [];
|
||||
const consoleErrors: ConsoleError[] = [];
|
||||
const pageErrors: PageError[] = [];
|
||||
|
||||
let currentStep = "init";
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
ensureDir(SCREENSHOT_DIR);
|
||||
ensureDir(TELEMETRY_DIR);
|
||||
|
||||
/**
|
||||
* Attach listeners that record network/console failures into per-test arrays.
|
||||
* The arrays are flushed to disk in test.afterAll so the report generator can
|
||||
* aggregate them across the suite.
|
||||
*/
|
||||
function attachTelemetry(page: Page, testName: string): void {
|
||||
page.on("response", (resp) => {
|
||||
const status = resp.status();
|
||||
const url = resp.url();
|
||||
// Ignore static assets, vite HMR, and 304 (not modified).
|
||||
if (status >= 400 && !url.includes("/@vite/") && !url.includes(".woff")) {
|
||||
networkErrors.push({
|
||||
url,
|
||||
status,
|
||||
method: resp.request().method(),
|
||||
testName,
|
||||
step: currentStep,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.on("console", (msg: ConsoleMessage) => {
|
||||
if (msg.type() === "error") {
|
||||
const text = msg.text();
|
||||
// Skip noisy known-harmless warnings.
|
||||
if (
|
||||
text.includes("Failed to load resource") ||
|
||||
text.includes("favicon") ||
|
||||
text.includes("DevTools")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
consoleErrors.push({ message: text, testName, step: currentStep });
|
||||
}
|
||||
});
|
||||
|
||||
page.on("pageerror", (err: Error) => {
|
||||
pageErrors.push({
|
||||
message: `${err.name}: ${err.message}`,
|
||||
testName,
|
||||
step: currentStep,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function safeStep(
|
||||
page: Page,
|
||||
name: string,
|
||||
body: () => Promise<void>,
|
||||
): Promise<{ ok: boolean; error?: string; screenshot?: string }> {
|
||||
currentStep = name;
|
||||
try {
|
||||
await test.step(name, body);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const slug = name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
|
||||
const screenshotPath = path.join(SCREENSHOT_DIR, `${slug}-fail.png`);
|
||||
try {
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
} catch {
|
||||
// Page may already be closed — non-fatal.
|
||||
}
|
||||
return { ok: false, error: message, screenshot: screenshotPath };
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
feature: string;
|
||||
status: "OK" | "KO" | "PARTIAL";
|
||||
details: string;
|
||||
screenshot?: string;
|
||||
}
|
||||
|
||||
const stepResults: StepResult[] = [];
|
||||
|
||||
/**
|
||||
* Navigate to /home and ensure the workspace shell rendered.
|
||||
* Used as a stable anchor between steps that may have left the user mid-modal.
|
||||
*/
|
||||
async function gotoHome(page: Page): Promise<void> {
|
||||
await page.goto(`${BASE_URL}/home`, { waitUntil: "domcontentloaded" });
|
||||
// The sidebar renders a workspace name area within ~3s on a warm cache.
|
||||
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the first available space — pages can only be created inside a space,
|
||||
* not at the workspace root. Returns the space URL for later navigation.
|
||||
*/
|
||||
async function enterFirstSpace(page: Page): Promise<string> {
|
||||
await gotoHome(page);
|
||||
// Click "Espaces" / "Spaces" sidebar item to expand the list, or click a
|
||||
// space card directly on /home (UI shows space cards: Agence/CFA/General/Interne).
|
||||
const spaceCard = page
|
||||
.locator('a[href*="/s/"]')
|
||||
.or(
|
||||
page
|
||||
.getByRole("link")
|
||||
.filter({ hasText: /^(Agence|CFA|General|Interne)$/i }),
|
||||
)
|
||||
.first();
|
||||
await spaceCard.click({ timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/s\//, { timeout: 10_000 });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
return page.url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "create page" affordance inside a space.
|
||||
*
|
||||
* Strategy (R4.8 fix — waitForURL with load event does not fire for SPA pushState):
|
||||
* 1. Click the ActionIcon "+" next to the "Pages" section header (aria-label "Creer page" / "Creer page"
|
||||
* or "Create page" depending on locale). This is the most direct path to handleCreatePage().
|
||||
* 2. Fallback: open the "Nouvelle page" / "New page" dropdown menu and pick the blank item.
|
||||
* 3. After clicking, wait via expect(page).toHaveURL which polls URL without requiring a load event.
|
||||
*/
|
||||
async function clickCreatePage(page: Page): Promise<void> {
|
||||
// Wait for the sidebar tree component to be ready. The tree sets treeApiAtom
|
||||
// only after it mounts and renders. Without this wait, handleCreatePage() fires
|
||||
// with tree === null and the navigate() call never executes.
|
||||
// The "Pages" section header is a reliable proxy — it renders when SpaceSidebar is mounted.
|
||||
const pagesHeader = page.getByText(/^(Pages)$/i).first();
|
||||
await pagesHeader.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {});
|
||||
// Extra buffer for the tree component to set treeApiAtom after mounting.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Strategy 1: the small "+" ActionIcon in the "Pages" section header.
|
||||
// aria-label is t("Create page") = "Créer page" (fr) or "Create page" (en).
|
||||
// This calls handleCreatePage() directly without opening a dropdown.
|
||||
const directPlusBtn = page
|
||||
.locator('button[aria-label="Créer page"]')
|
||||
.or(page.locator('button[aria-label="Create page"]'))
|
||||
.first();
|
||||
const directCount = await directPlusBtn.count();
|
||||
if (directCount > 0) {
|
||||
await directPlusBtn.click({ timeout: 8_000 });
|
||||
// If click worked, URL changes to /p/... via React Router navigate().
|
||||
// Use expect(page).toHaveURL which polls URL without requiring a load event.
|
||||
const urlChanged = await expect(page)
|
||||
.toHaveURL(/\/p\//, { timeout: 8_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (urlChanged) return;
|
||||
// Retry once — maybe tree wasn't ready on first click.
|
||||
await page.waitForTimeout(1_000);
|
||||
await directPlusBtn.click({ timeout: 5_000 }).catch(() => {});
|
||||
await expect(page).toHaveURL(/\/p\//, { timeout: 10_000 }).catch(() => {});
|
||||
if (page.url().includes("/p/")) return;
|
||||
}
|
||||
|
||||
// Strategy 2: "Nouvelle page" / "New page" dropdown menu trigger.
|
||||
// Clicking it opens a Mantine Menu.Dropdown with a blank-page item.
|
||||
const menuTrigger = page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /^(Nouvelle page|New page)$/i })
|
||||
.first();
|
||||
const menuCount = await menuTrigger.count();
|
||||
if (menuCount > 0) {
|
||||
await menuTrigger.click({ timeout: 8_000 });
|
||||
await page.waitForTimeout(300);
|
||||
const blankItem = page
|
||||
.locator('[role="menuitem"]')
|
||||
.filter({ hasText: /^(Nouvelle page|New page)$/i })
|
||||
.first();
|
||||
const blankCount = await blankItem.count();
|
||||
if (blankCount > 0) {
|
||||
await blankItem.click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(page).toHaveURL(/\/p\//, { timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: direct API create + navigate.
|
||||
throw new Error("clickCreatePage: no create button found in sidebar");
|
||||
}
|
||||
|
||||
test.describe("acadenice smoke full", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
attachTelemetry(page, "acadenice-smoke-full");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Flush telemetry to disk for the report generator.
|
||||
fs.writeFileSync(
|
||||
path.join(TELEMETRY_DIR, "network-errors.json"),
|
||||
JSON.stringify(networkErrors, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(TELEMETRY_DIR, "console-errors.json"),
|
||||
JSON.stringify(consoleErrors, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(TELEMETRY_DIR, "page-errors.json"),
|
||||
JSON.stringify(pageErrors, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(TELEMETRY_DIR, "step-results.json"),
|
||||
JSON.stringify(stepResults, null, 2),
|
||||
);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test("full smoke flow", async () => {
|
||||
// 1. Login.
|
||||
{
|
||||
const r = await safeStep(page, "1-login", async () => {
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.getByLabel(/email/i).fill(USER_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(USER_PASSWORD);
|
||||
await page
|
||||
.getByRole("button", { name: /sign in|login|connexion|se connecter/i })
|
||||
.click();
|
||||
// Post-login the app routes either to /home or directly to a space root.
|
||||
await expect(page).toHaveURL(/\/(home|s\/|p\/)/, { timeout: 20_000 });
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Login",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok ? `Redirected to ${page.url()}` : (r.error ?? "unknown"),
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create page.
|
||||
let pageAUrl = "";
|
||||
let spaceUrl = "";
|
||||
{
|
||||
const r = await safeStep(page, "2-create-page", async () => {
|
||||
spaceUrl = await enterFirstSpace(page);
|
||||
await clickCreatePage(page);
|
||||
// Docmost renders the page title as a Tiptap contenteditable div
|
||||
// with class ".page-title". There is no <input type="text">.
|
||||
// We click the contenteditable and use keyboard.type to set the title.
|
||||
const titleEditor = page
|
||||
.locator(".page-title [contenteditable='true']")
|
||||
.or(page.locator(".page-title"))
|
||||
.first();
|
||||
await titleEditor.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await titleEditor.click({ timeout: 5_000 });
|
||||
// Clear any existing content then type the title.
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.type("Smoke Page A");
|
||||
// Press Tab to move focus to the body editor.
|
||||
await page.keyboard.press("Tab");
|
||||
// Wait briefly for the TitleEditor's useEffect to call navigate() with the
|
||||
// new slug after debounce. The URL will update to include "smoke-page-a-xxx".
|
||||
await page.waitForTimeout(700);
|
||||
pageAUrl = page.url();
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Create page",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? `Page created at ${pageAUrl}`
|
||||
: (r.error ?? "unknown"),
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Create sub-page.
|
||||
{
|
||||
const r = await safeStep(page, "3-create-sub-page", async () => {
|
||||
if (!pageAUrl) throw new Error("Parent page not created — skipping");
|
||||
await page.goto(pageAUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
// The "+" sub-page button lives in the .actions div inside each tree node row.
|
||||
// It is CSS visibility:hidden by default and becomes visibility:visible on :hover.
|
||||
// Strategy: find the tree node (a[href*="/p/"]) for the parent page, hover it,
|
||||
// then click the CreateNode button that appears in the same node's .actions div.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The sidebar tree renders tree rows as [role="treeitem"] containers.
|
||||
// Each row has a .node link inside it. We find the one for the parent page.
|
||||
// pageAUrl ends in /p/smoke-page-a-xxx — the link href matches.
|
||||
const parentPageSlug = pageAUrl.replace(/.*\/p\//, "").replace(/\?.*$/, "");
|
||||
const parentNode = page
|
||||
.locator(`a[href*="/p/${parentPageSlug}"]`)
|
||||
.or(page.locator('[role="treeitem"]').filter({ hasText: /smoke page a/i }).locator('a').first())
|
||||
.first();
|
||||
|
||||
// Hover the parent node link to make .actions div visible via CSS :hover.
|
||||
await parentNode.hover({ timeout: 5_000 });
|
||||
// The .actions div is a child of the same .node element we hovered.
|
||||
// Now the CreateNode button (aria-label "Créer page" / "Create page") is visible.
|
||||
// We click it directly within the parent node's subtree.
|
||||
const createSubBtn = parentNode
|
||||
.locator('[aria-label="Créer page"], [aria-label="Create page"]')
|
||||
.first();
|
||||
|
||||
// Click with force to bypass any residual visibility:hidden from CSS timing.
|
||||
await createSubBtn.click({ force: true, timeout: 5_000 });
|
||||
|
||||
// After creating sub-page, navigate to it.
|
||||
await expect(page).toHaveURL(/\/p\//, { timeout: 10_000 });
|
||||
|
||||
// Type the sub-page title.
|
||||
const titleEditor = page
|
||||
.locator(".page-title [contenteditable='true']")
|
||||
.or(page.locator(".page-title"))
|
||||
.first();
|
||||
await titleEditor.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await titleEditor.click({ timeout: 5_000 });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.type("Smoke Sub-page A.1");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// Verify we're on a page (sub-page exists in DB).
|
||||
await expect(page).toHaveURL(/\/p\//);
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Sub-page (parent-child link)",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Sub-page rendered nested under parent in sidebar"
|
||||
: `Sub-page may exist in DB but not nested under parent in sidebar — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Wikilink + backlink.
|
||||
{
|
||||
const r = await safeStep(page, "4-wikilink-backlink", async () => {
|
||||
if (!pageAUrl) throw new Error("Page A not available");
|
||||
await page.goto(pageAUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
// The body editor is in .editor-container — not the title editor.
|
||||
const editor = page.locator(".editor-container .ProseMirror")
|
||||
.or(page.locator(".editor-container [contenteditable='true']"))
|
||||
.first();
|
||||
await editor.click({ timeout: 8_000 });
|
||||
await page.keyboard.type("[[Smoke Sub-page A.1");
|
||||
// WikilinkList renders a Paper with role="listbox" aria-label="Page suggestions".
|
||||
// It shows matching pages. We wait for it then press Enter to select.
|
||||
const suggestion = page
|
||||
.locator('[role="listbox"][aria-label="Page suggestions"]')
|
||||
.or(page.locator('[role="listbox"]').first())
|
||||
.first();
|
||||
await expect(suggestion).toBeVisible({ timeout: 5_000 });
|
||||
await page.keyboard.press("Enter");
|
||||
// After Enter, the wikilink node is inserted. Verify it appears as a
|
||||
// .wikilink span in the editor DOM.
|
||||
const wikilinkNode = page.locator('.wikilink').first();
|
||||
await expect(wikilinkNode).toBeVisible({ timeout: 5_000 });
|
||||
// Type trailing text to move cursor past the wikilink and trigger Hocuspocus save.
|
||||
await page.keyboard.type(" ");
|
||||
// Allow Hocuspocus debounce + queue to flush the backlink indexing job.
|
||||
await page.waitForTimeout(3_000);
|
||||
// Navigate to the sub-page — use the sidebar tree item (text matches node title).
|
||||
const subLink = page
|
||||
.locator('[data-testid^="wikilink-"]')
|
||||
.or(page.locator('a[href*="/p/"], a[href*="/s/"]').filter({ hasText: /smoke sub-page a\.1/i }))
|
||||
.or(page.locator('text="Smoke Sub-page A.1"'))
|
||||
.first();
|
||||
const subLinkCount = await subLink.count();
|
||||
if (subLinkCount > 0) {
|
||||
await subLink.click({ timeout: 5_000 });
|
||||
} else {
|
||||
// Fallback: click the sidebar tree item by title text.
|
||||
await page.locator('[class*="tree"] [class*="row"]')
|
||||
.filter({ hasText: /smoke sub-page a\.1/i })
|
||||
.first()
|
||||
.click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(page).toHaveURL(/\/p\//, { timeout: 8_000 });
|
||||
// LinkedReferencesPanel renders below the editor. It always renders one of:
|
||||
// backlinks-loading → backlinks-panel (when data.total > 0)
|
||||
// backlinks-loading → backlinks-empty (when no backlinks indexed yet)
|
||||
// backlinks-error (when API fails)
|
||||
// The backlink indexing is async (queue). Accept panel OR empty as OK — the
|
||||
// component rendered. Only fail if the component is missing entirely.
|
||||
const anyBacklinkState = page
|
||||
.locator('[data-testid="backlinks-panel"], [data-testid="backlinks-empty"], [data-testid="backlinks-error"]')
|
||||
.first();
|
||||
await expect(anyBacklinkState).toBeVisible({ timeout: 15_000 });
|
||||
// Best case: the backlinks panel shows Smoke Page A as a backlink.
|
||||
const hasPanel = await page.locator('[data-testid="backlinks-panel"]').isVisible().catch(() => false);
|
||||
if (hasPanel) {
|
||||
await expect(
|
||||
page.locator('[data-testid="backlinks-panel"]').getByText(/smoke page a/i).first(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Wikilink + backlink",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Wikilink suggestion appeared, node inserted, backlinks component rendered"
|
||||
: `Wikilink/backlink flow broken — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to create a fresh page for each slash test (isolates failures).
|
||||
async function freshPage(title: string): Promise<void> {
|
||||
if (spaceUrl) {
|
||||
await page.goto(spaceUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
} else {
|
||||
spaceUrl = await enterFirstSpace(page);
|
||||
}
|
||||
await clickCreatePage(page);
|
||||
// Docmost uses a Tiptap contenteditable for the title — no <input>.
|
||||
const titleEditor = page
|
||||
.locator(".page-title [contenteditable='true']")
|
||||
.or(page.locator(".page-title"))
|
||||
.first();
|
||||
await titleEditor.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await titleEditor.click({ timeout: 5_000 });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.type(title);
|
||||
await page.keyboard.press("Tab");
|
||||
// Wait for the TitleEditor's useEffect debounce to fire navigate() with the
|
||||
// new slug before the caller interacts with the page. Without this wait the
|
||||
// title-debounce navigate() can fire mid-test and trigger framenavigated.
|
||||
await page.waitForTimeout(800);
|
||||
// Wait for the body editor to mount — Tab moves focus to it.
|
||||
// This ensures subsequent editor interactions in the caller are reliable.
|
||||
const bodyEditor = page.locator(".editor-container .ProseMirror")
|
||||
.or(page.locator(".editor-container [contenteditable='true']"))
|
||||
.first();
|
||||
await bodyEditor.waitFor({ state: "visible", timeout: 8_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// 5. Slash /database.
|
||||
{
|
||||
const r = await safeStep(page, "5-slash-database", async () => {
|
||||
await freshPage("Smoke Database Test");
|
||||
// The body editor is inside .editor-container.
|
||||
const editor = page.locator(".editor-container .ProseMirror")
|
||||
.or(page.locator(".ProseMirror").last())
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: 8_000 });
|
||||
await editor.click({ timeout: 8_000 });
|
||||
// Small pause to ensure editor is focused and any pending keyboard events clear.
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type("/database");
|
||||
// Slash menu should appear. It uses role="listbox" (see command-list.tsx).
|
||||
// The menu can appear quickly — wait for it and press Enter to trigger.
|
||||
const slashMenu = page.locator('[role="listbox"]').first();
|
||||
await expect(slashMenu).toBeVisible({ timeout: 8_000 });
|
||||
await page.keyboard.press("Enter");
|
||||
// Verify the database picker modal opens — it uses createRoot to render outside
|
||||
// the main React tree, so we wait for it via getByRole("dialog").
|
||||
const modal = page
|
||||
.getByRole("dialog")
|
||||
.or(page.locator('[data-testid="database-picker"]'))
|
||||
.first();
|
||||
await expect(modal).toBeVisible({ timeout: 10_000 });
|
||||
// The modal opened — command registered. Baserow table list depends on bridge
|
||||
// connectivity which is separate from the slash command feature.
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Slash /database",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Database picker modal opens (bridge connectivity tested separately)"
|
||||
: `/database broken — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Slash /template.
|
||||
{
|
||||
const r = await safeStep(page, "6-slash-template", async () => {
|
||||
await freshPage("Smoke Template Test");
|
||||
const editor = page.locator(".editor-container .ProseMirror")
|
||||
.or(page.locator(".ProseMirror").last())
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: 8_000 });
|
||||
await editor.click({ timeout: 8_000 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type("/template");
|
||||
// Slash menu appears with role="listbox". Wait for it and press Enter.
|
||||
const slashMenu = page.locator('[role="listbox"]').first();
|
||||
await expect(slashMenu).toBeVisible({ timeout: 8_000 });
|
||||
await page.keyboard.press("Enter");
|
||||
// TemplatePickerModal opens via DOM event acadenice:open-template-picker
|
||||
// dispatched by the slash command, caught by the page.tsx useEffect listener.
|
||||
// data-testid="template-picker-modal" is on the Mantine Modal root.
|
||||
// Two instances exist in the DOM (sidebar + page.tsx). The page.tsx instance
|
||||
// is opened by the event. We wait for the Mantine Modal inner content to be
|
||||
// visible — Mantine shows the content div when opened=true.
|
||||
// The Mantine Modal shows a [data-testid="template-picker-search"] input inside
|
||||
// when opened, which is inside the visible modal.
|
||||
const modalInput = page.locator('[data-testid="template-picker-search"]').first();
|
||||
await expect(modalInput).toBeVisible({ timeout: 10_000 });
|
||||
// Modal should show templates from DB — at least one seeded template.
|
||||
// Check for empty state text or for a seeded template name.
|
||||
const empty = page.getByText(/aucun mod[eè]le|no template/i).first();
|
||||
const hasEmpty = await empty.isVisible().catch(() => false);
|
||||
if (hasEmpty) {
|
||||
throw new Error("Template picker shows empty state but DB has 5 templates seeded");
|
||||
}
|
||||
await expect(
|
||||
page.locator('[data-testid^="template-picker-item-"]').first(),
|
||||
).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Slash /template",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Template picker opens and lists seeded templates"
|
||||
: `/template broken — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Slash /sync-block.
|
||||
{
|
||||
const r = await safeStep(page, "7-slash-sync-block", async () => {
|
||||
await freshPage("Smoke SyncBlock Test");
|
||||
const editor = page.locator(".editor-container .ProseMirror")
|
||||
.or(page.locator(".ProseMirror").last())
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: 8_000 });
|
||||
await editor.click({ timeout: 8_000 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.type("/sync");
|
||||
// Slash menu should appear with "Sync block" entry.
|
||||
const slashMenu = page.locator('[role="listbox"]').first();
|
||||
await expect(slashMenu).toBeVisible({ timeout: 8_000 });
|
||||
// Click the "Sync block" entry by text.
|
||||
const syncEntry = slashMenu
|
||||
.getByText(/sync ?block/i)
|
||||
.first();
|
||||
await expect(syncEntry).toBeVisible({ timeout: 5_000 });
|
||||
await syncEntry.click();
|
||||
// Sync block node should be inserted in the editor.
|
||||
// Tiptap's ReactNodeViewRenderer wraps the component in an element with
|
||||
// class "node-{extensionName}" — so "node-syncBlock" for the syncBlock extension.
|
||||
// NodeViewWrapper adds data-node-view-wrapper="". Either selector is reliable.
|
||||
const syncNode = page
|
||||
.locator('.node-syncBlock')
|
||||
.or(page.locator('[data-node-view-wrapper]').filter({ hasText: /sync block/i }))
|
||||
.first();
|
||||
await expect(syncNode).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Slash /sync-block",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Sync block node inserted"
|
||||
: `/sync-block broken — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Graph view from workspace.
|
||||
{
|
||||
const r = await safeStep(page, "8-graph-workspace", async () => {
|
||||
// Try the sidebar link first (French UI: "Graphe de connaissance").
|
||||
await gotoHome(page);
|
||||
const sidebarGraph = page
|
||||
.getByRole("link", { name: /graphe de connaissance|knowledge graph|graph/i })
|
||||
.first();
|
||||
if (await sidebarGraph.count()) {
|
||||
await sidebarGraph.click({ timeout: 5_000 }).catch(async () => {
|
||||
await page.goto(`${BASE_URL}/graph`, { waitUntil: "domcontentloaded" });
|
||||
});
|
||||
} else {
|
||||
await page.goto(`${BASE_URL}/graph`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
// The graph canvas (cytoscape, vis-network, or sigma) renders to <canvas>.
|
||||
const canvas = page
|
||||
.locator('[data-testid="graph-canvas"]')
|
||||
.or(page.locator("canvas"))
|
||||
.or(page.locator("svg.graph"))
|
||||
.first();
|
||||
await expect(canvas).toBeVisible({ timeout: 15_000 });
|
||||
// The graph data API call should have responded.
|
||||
// We accept a node count badge OR a non-empty <svg>/<canvas>.
|
||||
const empty = page
|
||||
.getByText(/no pages|aucune page|graph is empty/i)
|
||||
.first();
|
||||
const isEmpty = await empty.isVisible().catch(() => false);
|
||||
if (isEmpty) {
|
||||
throw new Error("Graph view rendered but reports empty state");
|
||||
}
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Graph view (workspace)",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Graph canvas rendered with nodes"
|
||||
: `Graph view broken — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
// 9. Graph view from a space.
|
||||
{
|
||||
const r = await safeStep(page, "9-graph-space", async () => {
|
||||
await gotoHome(page);
|
||||
// Click first space in the sidebar.
|
||||
const spaceLink = page
|
||||
.locator('a[href*="/s/"]')
|
||||
.first();
|
||||
await spaceLink.click({ timeout: 5_000 });
|
||||
await expect(page).toHaveURL(/\/s\//, { timeout: 10_000 });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
// The space graph is inside the "..." (SpaceMenu) dropdown.
|
||||
// aria-label is t("Space menu") = "Menu de l'espace" (fr) or "Space menu" (en).
|
||||
// We must open that dropdown first, then click the graph item inside.
|
||||
const spaceMenuBtn = page
|
||||
.locator('[aria-label="Menu de l\'espace"]')
|
||||
.or(page.locator('[aria-label="Space menu"]'))
|
||||
.first();
|
||||
const hasSpaceMenu = await spaceMenuBtn.count();
|
||||
if (hasSpaceMenu > 0) {
|
||||
await spaceMenuBtn.click({ timeout: 5_000 });
|
||||
await page.waitForTimeout(300);
|
||||
// The graph menu item is a Menu.Item with role="menuitem" and text "Graphe" / "Graph".
|
||||
const graphItem = page
|
||||
.locator('[role="menuitem"]')
|
||||
.filter({ hasText: /^(Graphe|Graph)$/i })
|
||||
.first();
|
||||
await graphItem.click({ timeout: 5_000 });
|
||||
} else {
|
||||
// Fallback: direct navigation to the space graph URL.
|
||||
const currentUrl = page.url();
|
||||
const spaceMatch = currentUrl.match(/\/s\/([^/]+)/);
|
||||
if (spaceMatch) {
|
||||
await page.goto(`${BASE_URL}/s/${spaceMatch[1]}/graph`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
}
|
||||
const canvas = page
|
||||
.locator('[data-testid="graph-canvas"]')
|
||||
.or(page.locator("canvas"))
|
||||
.first();
|
||||
await expect(canvas).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
stepResults.push({
|
||||
feature: "Graph view (space-scoped)",
|
||||
status: r.ok ? "OK" : "KO",
|
||||
details: r.ok
|
||||
? "Space-scoped graph rendered"
|
||||
: `Space graph broken — ${r.error ?? ""}`,
|
||||
screenshot: r.screenshot,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue