Compare commits
10
Commits
087945472a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf119eb0d | ||
|
|
5c04defe31 | ||
|
|
f6b4f7f79b | ||
|
|
8a576f6884 | ||
|
|
bf2c9ae4fb | ||
|
|
67569e2d16 | ||
|
|
31283ada26 | ||
|
|
ba5bd8288d | ||
|
|
0ca2846f2b | ||
|
|
8ff530bf99 |
No files matched your search
@@ -1,6 +1,9 @@
|
|||||||
# Variables exposées au client (préfixées NEXT_PUBLIC_)
|
# Variables exposées au client (préfixées NEXT_PUBLIC_)
|
||||||
# NEXT_PUBLIC_API_URL=http://localhost:3000
|
# NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Base interne pour les fetch SSR (port interne du serveur Next.js, défaut 3000)
|
||||||
|
# API_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Implémentation repository active : mysql | mongo
|
# Implémentation repository active : mysql | mongo
|
||||||
DB_IMPL=mysql
|
DB_IMPL=mysql
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ cp .env.example .env
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Le service web est exposé sur **http://localhost:4000** avec les bases MySQL (port 13306) et MongoDB (port 27018). Le mode opératoire complet (démarrage, vérifications, dev local, arrêt, dépannage) est décrit dans [MOD_OP.md](MOD_OP.md).
|
Le service web est exposé sur **http://localhost:4000** avec les bases MySQL (port 13306) et MongoDB (port 27018). Le mode opératoire complet (démarrage, vérifications, dev local, arrêt, dépannage) est décrit dans [docs/MOD_OP.md](docs/MOD_OP.md).
|
||||||
|
|
||||||
## Structure du projet
|
## Structure du projet
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
+21
-57
@@ -1,70 +1,34 @@
|
|||||||
@startuml c4-container
|
@startuml c4-container
|
||||||
' C4 modèle — Niveau 2 : Conteneurs (syntaxe native PlantUML, compatible anciennes versions)
|
' C4 modèle — Niveau 2 : Conteneurs
|
||||||
' Application web Flashcards (SPA/PWA)
|
' Bibliothèque standard : C4_Container.puml inclut lui-même C4_Context.puml,
|
||||||
|
' et définit les macros Container / ContainerDb utilisées ici.
|
||||||
hide stereotype
|
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
|
||||||
skinparam backgroundColor #FFFFFF
|
|
||||||
|
|
||||||
' ===== Styles par type =====
|
|
||||||
skinparam rectangle<<person>> {
|
|
||||||
BackgroundColor #08427B
|
|
||||||
BorderColor #073B6F
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam rectangle<<system>> {
|
|
||||||
'BackgroundColor #1168BD
|
|
||||||
BorderColor #3C7FC0
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam rectangle<<container>> {
|
|
||||||
BackgroundColor #438DD5
|
|
||||||
BorderColor #135F97
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam rectangle<<external_system>> {
|
|
||||||
BackgroundColor #999999
|
|
||||||
BorderColor #8A8A8A
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam arrow {
|
|
||||||
Color #707070
|
|
||||||
}
|
|
||||||
|
|
||||||
title Conteneurs — Flashcards (Niveau 2)
|
title Conteneurs — Flashcards (Niveau 2)
|
||||||
|
|
||||||
' ===== Personne =====
|
Person(user, "Utilisateur", "Navigateur / mobile (accès au jeu et au CRUD)")
|
||||||
rectangle "Utilisateur\nNavigateur / mobile\n(accès au jeu et au CRUD)" <<person>> as User
|
|
||||||
|
|
||||||
' ===== Système Flashcards et ses conteneurs =====
|
System_Boundary(flashcards, "Application Flashcards") {
|
||||||
rectangle "Application Flashcards [Système]\nSPA/PWA Next.js — double persistance simultanée" <<system>> as Flashcards {
|
|
||||||
|
|
||||||
rectangle "SPA React (Next.js)\n[Conteneur : Web Browser]\nRendu de l'UI, état local, navigation" <<container>> as Spa
|
Container(spa, "SPA React (Next.js)", "Web Browser", "Rendu de l'UI, état local, navigation, mode hors-ligne (service worker)")
|
||||||
|
|
||||||
rectangle "API Next.js / App Router\n[Conteneur : Node.js]\nCardController > CardService :\nliste, CRUD, tirage aléatoire, thèmes\n(seul conteneur qui accède aux données)" <<container>> as Api
|
Container(api, "API Next.js / App Router", "Node.js", "CardController > CardService : liste, CRUD, tirage aléatoire, thèmes, suggestions de tags. Seul conteneur qui accède aux données.")
|
||||||
|
|
||||||
rectangle "CardRepository (générique)\n[Abstraction de données]\nContracts : findAll, findById,\nfindRandom, findRandomByTheme,\nfindThemes, create, update, delete" <<container>> as Repo
|
Container(repo, "CardRepository (générique)", "Abstraction de données", "Contracts : findAll, findById, findRandom, findRandomByTheme, findThemes, tags, create, update, delete")
|
||||||
|
|
||||||
rectangle "Implémentation MySQL\n[Conteneur : librairie]\nImplémentation SQL du repository" <<container>> as RepoMysql
|
Container(repoMysql, "Implémentation MySQL", "Library (mysql2)", "Implémentation SQL du repository")
|
||||||
rectangle "Implémentation MongoDB\n[Conteneur : librairie]\nImplémentation Mongo du repository" <<container>> as RepoMongo
|
Container(repoMongo, "Implémentation MongoDB", "Library (mongodb)", "Implémentation Mongo du repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
' ===== Systèmes externes =====
|
ContainerDb(mysql, "Base MySQL", "SGBD relationnel", "Persistance des cartes (table cards) + volume Docker")
|
||||||
rectangle "Base MySQL\n[Conteneur externe : SGBD]\nPersistance des cartes (table cards)\n+ stockage Docker avec volume" <<external_system>> as MySql
|
ContainerDb(mongo, "Base MongoDB", "SGBD documentaire", "Persistance des cartes (collection cards) + volume Docker")
|
||||||
rectangle "Base MongoDB\n[Conteneur externe : SGBD]\nPersistance des cartes (collection cards)\n+ stockage Docker avec volume" <<external_system>> as Mongo
|
|
||||||
'rectangle "Docker / docker-compose\n[infrastructure]\nOrchestration des conteneurs\nweb + bases + volumes" <<external_system>> as Docker
|
Rel(user, spa, "Navigue et interagit (HTTP/HTTPS)")
|
||||||
|
Rel(spa, api, "Appels API JSON (jeu, CRUD, révélation)")
|
||||||
|
Rel(api, repo, "Utilise uniquement l'abstraction")
|
||||||
|
Rel(repo, repoMysql, "implémente")
|
||||||
|
Rel(repo, repoMongo, "implémente")
|
||||||
|
Rel(repoMysql, mysql, "Requêtes SQL (table cards)")
|
||||||
|
Rel(repoMongo, mongo, "Requêtes Mongo (collection cards)")
|
||||||
|
|
||||||
' ===== Relations =====
|
|
||||||
User -[#707070]-> Spa : "Navigue et interagit (HTTP/HTTPS)"
|
|
||||||
Spa -[#707070]-> Api : "Appels API JSON (dialogues, CRUD,\ntirage, révélation de réponse)"
|
|
||||||
Api -[#707070]down-> Repo : "Utilise uniquement l'abstraction"
|
|
||||||
Repo -[#707070]-> RepoMysql : "implémente"
|
|
||||||
Repo -[#707070]-> RepoMongo : "implémente"
|
|
||||||
Api -[#707070]down-> MySql : "Tables SQL via repository\n(selectionné par config/driver)"
|
|
||||||
Api -[#707070]down-> Mongo : "Requêtes Mongo via repository\n(selectionné par config/driver)"
|
|
||||||
'Docker -[#707070]-> MySql : "Démarre / arrête (docker-compose)"
|
|
||||||
'Docker -[#707070]-> Mongo : "Démarre / arrête (docker-compose)"
|
|
||||||
@enduml
|
@enduml
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
+11
-47
@@ -1,55 +1,19 @@
|
|||||||
@startuml c4-context
|
@startuml c4-context
|
||||||
' C4 modèle — Niveau 1 : Contexte système (syntaxe native PlantUML, compatible anciennes versions)
|
' C4 modèle — Niveau 1 : Contexte système
|
||||||
' Application web Flashcards (SPA/PWA)
|
' Bibliothèque standard : !include C4_Context.puml
|
||||||
|
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
|
||||||
hide stereotype
|
|
||||||
skinparam backgroundColor #FFFFFF
|
|
||||||
|
|
||||||
' ===== Styles par type =====
|
|
||||||
skinparam rectangle<<person>> {
|
|
||||||
BackgroundColor #08427B
|
|
||||||
BorderColor #073B6F
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam rectangle<<external_person>> {
|
|
||||||
BackgroundColor #686868
|
|
||||||
BorderColor #8A8A8A
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam rectangle<<system>> {
|
|
||||||
BackgroundColor #1168BD
|
|
||||||
BorderColor #3C7FC0
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam rectangle<<external_system>> {
|
|
||||||
BackgroundColor #999999
|
|
||||||
BorderColor #8A8A8A
|
|
||||||
FontColor #FFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
skinparam arrow {
|
|
||||||
Color #707070
|
|
||||||
}
|
|
||||||
|
|
||||||
title Contexte système — Flashcards (Niveau 1)
|
title Contexte système — Flashcards (Niveau 1)
|
||||||
|
|
||||||
' ===== Personnes =====
|
Person(user, "Utilisateur", "Joueur et contributeur de cartes (CRUD) : code, dev, culture tech")
|
||||||
rectangle "Utilisateur\nJoueur et contributeur de cartes\n(CRUD) : code, dev, culture tech" <<person>> as User
|
|
||||||
|
|
||||||
' ===== Système à modéliser =====
|
System(flashcards, "App Flashcards", "SPA/PWA Next.js — tirage de cartes au hasard, tirage par thème, CRUD des cartes. Double persistance simultanée MySQL + MongoDB via une couche générique.")
|
||||||
rectangle "App Flashcards\nSPA/PWA Next.js — tirage de cartes au\nhasard, tirage par thème, CRUD des cartes.\nDouble persistance simultanée\nMySQL + MongoDB via couche générique." <<system>> as Flashcards
|
|
||||||
|
|
||||||
' ===== Systèmes externes =====
|
System_Ext(mysql, "MySQL", "Base relationnelle (conteneur Docker) — table cards")
|
||||||
rectangle "MySQL\nBase relationnelle\n(conteneur Docker)" <<external_system>> as MySql
|
System_Ext(mongo, "MongoDB", "Base documentaire (conteneur Docker) — collection cards")
|
||||||
rectangle "MongoDB\nBase documentaire\n(conteneur Docker)" <<external_system>> as Mongo
|
|
||||||
'rectangle "Docker / docker-compose\nOrchestration des conteneurs\n(web, bases) + volumes persistants" <<external_system>> as Docker
|
Rel(user, flashcards, "Joue, crée, modifie, supprime,\nconsulte et révèle des cartes", "HTTPS")
|
||||||
|
Rel(flashcards, mysql, "Lit / écrit via CardRepository", "SQL")
|
||||||
|
Rel(flashcards, mongo, "Lit / écrit via CardRepository", "MongoDB")
|
||||||
|
|
||||||
User -[#707070]-> Flashcards : Joue, crée, modifie, supprime,\nconsulte et révèle des cartes
|
|
||||||
Flashcards -[#707070]-> MySql : Lit / écrit (CardRepository)
|
|
||||||
Flashcards -[#707070]-> Mongo : Lit / écrit (CardRepository)
|
|
||||||
'Docker -[#707070]-> MySql : Démarre et gère
|
|
||||||
'Docker -[#707070]-> Mongo : Démarre et gère
|
|
||||||
@enduml
|
@enduml
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@startuml
|
@startuml classes
|
||||||
' Diagramme de classes — Application web Flashcards (Architecture : couche d'accès générique)
|
' Diagramme de classes — Application web Flashcards (Architecture : couche d'accès générique)
|
||||||
skinparam classAttributeIconSize 0
|
skinparam classAttributeIconSize 0
|
||||||
skinparam classFontStyle bold
|
skinparam classFontStyle bold
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -1,4 +1,4 @@
|
|||||||
@startuml
|
@startuml sequence
|
||||||
' Diagramme de séquence — "Afficher une carte" puis "Révéler la réponse"
|
' Diagramme de séquence — "Afficher une carte" puis "Révéler la réponse"
|
||||||
' Application web Flashcards (SPA/PWA, couche d'accès générique MySQL/Mongo)
|
' Application web Flashcards (SPA/PWA, couche d'accès générique MySQL/Mongo)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
File renamed without changes.
File renamed without changes.
@@ -191,6 +191,7 @@ Traceabilité F-01 à F-10 (tests unitaires) :
|
|||||||
- [x] DoD finale pleinement vérifiée
|
- [x] DoD finale pleinement vérifiée
|
||||||
|
|
||||||
Notes Phase 5 :
|
Notes Phase 5 :
|
||||||
|
|
||||||
- Image web multi-étapes (node:22-alpine, pnpm, `output: 'standalone'`), port hôte **4000** (3000/3001 occupés par l'hôte).
|
- Image web multi-étapes (node:22-alpine, pnpm, `output: 'standalone'`), port hôte **4000** (3000/3001 occupés par l'hôte).
|
||||||
- Correction UTF-8 du seed MySQL : `02-seed.sh` avec `mysql --default-character-set=utf8mb4` (le `.sql` brut importait en double-encodage) ; lignes seed réparées en base via `CONVERT(BINARY(CONVERT(... USING latin1)) USING utf8mb4)`.
|
- Correction UTF-8 du seed MySQL : `02-seed.sh` avec `mysql --default-character-set=utf8mb4` (le `.sql` brut importait en double-encodage) ; lignes seed réparées en base via `CONVERT(BINARY(CONVERT(... USING latin1)) USING utf8mb4)`.
|
||||||
- Correction `parseTags()` dans le repository MySQL (`mysql2` auto-parse les colonnes JSON en tableaux).
|
- Correction `parseTags()` dans le repository MySQL (`mysql2` auto-parse les colonnes JSON en tableaux).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getCardService } from '@/services/card-service-factory'
|
||||||
|
import { GET } from './route'
|
||||||
|
|
||||||
|
vi.mock('@/services/card-service-factory', () => ({
|
||||||
|
getCardService: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('GET /api/tags', () => {
|
||||||
|
const service = vi.mocked(getCardService)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie la liste des tags', async () => {
|
||||||
|
const serviceMock = { tags: vi.fn().mockResolvedValue(['docker', 'scope']) }
|
||||||
|
service.mockReturnValue(serviceMock as never)
|
||||||
|
const res = await GET()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(['docker', 'scope'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propage les erreurs du service', async () => {
|
||||||
|
const serviceMock = { tags: vi.fn().mockRejectedValue(new Error('boom')) }
|
||||||
|
service.mockReturnValue(serviceMock as never)
|
||||||
|
const res = await GET()
|
||||||
|
expect(res.status).toBe(500)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { handleError } from '@/lib/http'
|
||||||
|
import { getCardService } from '@/services/card-service-factory'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const tags = await getCardService().tags()
|
||||||
|
return NextResponse.json(tags)
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ vi.mock('@/lib/api-client', () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ApiClientError: ApiClientErrorMock,
|
ApiClientError: ApiClientErrorMock,
|
||||||
cardsApi: { get: vi.fn() },
|
cardsApi: { get: vi.fn(), tags: vi.fn().mockResolvedValue([]) },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
|
import { CardDeleteButton } from '@/components/card-delete'
|
||||||
import { CardForm } from '@/components/card-form'
|
import { CardForm } from '@/components/card-form'
|
||||||
import type { Card, CardInput } from '@/domain/card'
|
import type { Card, CardInput } from '@/domain/card'
|
||||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||||
@@ -33,7 +34,10 @@ export default async function EditCardPage({ params }: { params: Promise<{ id: s
|
|||||||
>
|
>
|
||||||
← Retour au détail
|
← Retour au détail
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-2 text-2xl font-bold text-slate-900">{card.question}</h1>
|
<div className="mt-2 flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">{card.question}</h1>
|
||||||
|
<CardDeleteButton cardId={card.id} question={card.question} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardForm mode="edit" cardId={card.id} initial={initial} />
|
<CardForm mode="edit" cardId={card.id} initial={initial} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.mock('@/lib/api-client', () => ({
|
|||||||
ApiClientError: class extends Error {
|
ApiClientError: class extends Error {
|
||||||
status = 0
|
status = 0
|
||||||
},
|
},
|
||||||
cardsApi: { create: vi.fn() },
|
cardsApi: { create: vi.fn(), tags: vi.fn().mockResolvedValue([]) },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
|
|||||||
+31
-3
@@ -1,7 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { defaultCache } from '@serwist/turbopack/worker'
|
import { defaultCache } from '@serwist/turbopack/worker'
|
||||||
import type { PrecacheEntry, RuntimeCaching, SerwistGlobalConfig } from 'serwist'
|
import type { PrecacheEntry, RuntimeCaching, SerwistGlobalConfig } from 'serwist'
|
||||||
import { NetworkOnly, Serwist } from 'serwist'
|
import { ExpirationPlugin, NetworkFirst, Serwist } from 'serwist'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface WorkerGlobalScope extends SerwistGlobalConfig {
|
interface WorkerGlobalScope extends SerwistGlobalConfig {
|
||||||
@@ -11,11 +11,39 @@ declare global {
|
|||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope
|
declare const self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
// Ne jamais mettre en cache les appels API
|
// API : lectures (GET) en réseau d'abord, servies depuis le cache en secours
|
||||||
|
// hors-ligne. Les écritures (POST/PUT/DELETE) ne sont pas interceptées par le
|
||||||
|
// service worker et passent toujours par le réseau.
|
||||||
const runtimeCaching: RuntimeCaching[] = [
|
const runtimeCaching: RuntimeCaching[] = [
|
||||||
{
|
{
|
||||||
matcher: ({ sameOrigin, url }) => sameOrigin && url.pathname.startsWith('/api/'),
|
matcher: ({ sameOrigin, url }) => sameOrigin && url.pathname.startsWith('/api/'),
|
||||||
handler: new NetworkOnly(),
|
handler: new NetworkFirst({
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
cacheName: 'api-network-first',
|
||||||
|
plugins: [
|
||||||
|
new ExpirationPlugin({
|
||||||
|
maxEntries: 200,
|
||||||
|
maxAgeSeconds: 7 * 24 * 60 * 60,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Navigation : réseau d'abord (pages toujours fraîches après déploiement),
|
||||||
|
// cache en secours uniquement hors-ligne
|
||||||
|
{
|
||||||
|
matcher: ({ request }) => request.mode === 'navigate',
|
||||||
|
handler: new NetworkFirst({
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
cacheName: 'pages-network-first',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Payloads RSC des navigations côté client : réseau d'abord
|
||||||
|
{
|
||||||
|
matcher: ({ request, sameOrigin }) => sameOrigin && request.headers.get('rsc') === '1',
|
||||||
|
handler: new NetworkFirst({
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
cacheName: 'rsc-network-first',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
...defaultCache,
|
...defaultCache,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ vi.mock('@/lib/api-client', () => ({
|
|||||||
ApiClientError: class extends Error {
|
ApiClientError: class extends Error {
|
||||||
status = 0
|
status = 0
|
||||||
},
|
},
|
||||||
cardsApi: { create: vi.fn(), update: vi.fn() },
|
cardsApi: { create: vi.fn(), update: vi.fn(), tags: vi.fn() },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const routerMock = { push: vi.fn(), refresh: vi.fn() }
|
const routerMock = { push: vi.fn(), refresh: vi.fn() }
|
||||||
@@ -20,6 +20,8 @@ import { cardsApi } from '@/lib/api-client'
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(cardsApi.create).mockReset()
|
vi.mocked(cardsApi.create).mockReset()
|
||||||
vi.mocked(cardsApi.update).mockReset()
|
vi.mocked(cardsApi.update).mockReset()
|
||||||
|
vi.mocked(cardsApi.tags).mockReset()
|
||||||
|
vi.mocked(cardsApi.tags).mockResolvedValue([])
|
||||||
routerMock.push.mockReset()
|
routerMock.push.mockReset()
|
||||||
routerMock.refresh.mockReset()
|
routerMock.refresh.mockReset()
|
||||||
})
|
})
|
||||||
@@ -35,6 +37,7 @@ it('crée une carte puis navigue vers son détail (F-05)', async () => {
|
|||||||
question: 'Cest quoi un commit ?',
|
question: 'Cest quoi un commit ?',
|
||||||
response: 'Un snapshot',
|
response: 'Un snapshot',
|
||||||
theme: 'git',
|
theme: 'git',
|
||||||
|
tags: [],
|
||||||
})
|
})
|
||||||
await screen.findByText('Enregistrement…')
|
await screen.findByText('Enregistrement…')
|
||||||
})
|
})
|
||||||
@@ -57,6 +60,73 @@ it('pré-remplit le formulaire et enregistre une modification (F-06 édition)',
|
|||||||
question: 'Nouvelle question',
|
question: 'Nouvelle question',
|
||||||
response: 'Ancienne réponse',
|
response: 'Ancienne réponse',
|
||||||
theme: 'git',
|
theme: 'git',
|
||||||
|
tags: [],
|
||||||
})
|
})
|
||||||
expect(routerMock.push).toHaveBeenCalledWith('/cards/c1')
|
expect(routerMock.push).toHaveBeenCalledWith('/cards/c1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ajoute des tags (saisie + suggestion), applique le max de 3 et les envoie', async () => {
|
||||||
|
vi.mocked(cardsApi.tags).mockResolvedValue(['git', 'docker'])
|
||||||
|
vi.mocked(cardsApi.create).mockResolvedValue({ id: 'c1' } as never)
|
||||||
|
render(<CardForm mode="create" />)
|
||||||
|
await userEvent.type(screen.getByLabelText('Question'), 'Q ?')
|
||||||
|
await userEvent.type(screen.getByLabelText('Réponse'), 'R')
|
||||||
|
await userEvent.type(screen.getByLabelText('Thème'), 'git')
|
||||||
|
|
||||||
|
const tagInput = screen.getByLabelText('Ajouter un tag')
|
||||||
|
await userEvent.type(tagInput, 'gi')
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: '+ git' }))
|
||||||
|
|
||||||
|
await userEvent.type(tagInput, 'DOCKER')
|
||||||
|
await userEvent.keyboard('{Enter}')
|
||||||
|
|
||||||
|
await userEvent.type(tagInput, 'hook')
|
||||||
|
await userEvent.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(screen.getByText('Tags (3/3)')).toBeInTheDocument()
|
||||||
|
expect(tagInput).toBeDisabled()
|
||||||
|
await userEvent.type(tagInput, 'extra')
|
||||||
|
await userEvent.keyboard('{Enter}')
|
||||||
|
expect(screen.queryByText('#extra')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Créer la carte' }))
|
||||||
|
expect(vi.mocked(cardsApi.create)).toHaveBeenCalledWith({
|
||||||
|
question: 'Q ?',
|
||||||
|
response: 'R',
|
||||||
|
theme: 'git',
|
||||||
|
tags: ['git', 'docker', 'hook'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuse un tag en doublon et signale une erreur', async () => {
|
||||||
|
vi.mocked(cardsApi.tags).mockResolvedValue(['git'])
|
||||||
|
render(<CardForm mode="create" />)
|
||||||
|
const tagInput = screen.getByLabelText('Ajouter un tag')
|
||||||
|
await userEvent.type(tagInput, 'git')
|
||||||
|
await userEvent.keyboard('{Enter}')
|
||||||
|
await userEvent.type(tagInput, 'GIT')
|
||||||
|
await userEvent.keyboard('{Enter}')
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('déjà présent')
|
||||||
|
expect(screen.getAllByText('#git')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pré-remplit les tags en édition et permet d en retirer', async () => {
|
||||||
|
vi.mocked(cardsApi.update).mockResolvedValue({ id: 'c1' } as never)
|
||||||
|
render(
|
||||||
|
<CardForm
|
||||||
|
mode="edit"
|
||||||
|
cardId="c1"
|
||||||
|
initial={{ question: 'A', response: 'R', theme: 'git', tags: ['git', 'scope'] }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Tags (2/3)')).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Retirer le tag git' }))
|
||||||
|
expect(screen.getByText('Tags (1/3)')).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
|
||||||
|
expect(vi.mocked(cardsApi.update)).toHaveBeenCalledWith('c1', {
|
||||||
|
question: 'A',
|
||||||
|
response: 'R',
|
||||||
|
theme: 'git',
|
||||||
|
tags: ['scope'],
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,29 +1,89 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { CardInput } from '@/domain/card'
|
import type { CardInput } from '@/domain/card'
|
||||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||||
|
|
||||||
|
const MAX_TAGS = 3
|
||||||
|
|
||||||
interface CardFormProps {
|
interface CardFormProps {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
cardId?: string
|
cardId?: string
|
||||||
initial?: CardInput
|
initial?: CardInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTag(value: string): string {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
export function CardForm({ mode, cardId, initial }: CardFormProps) {
|
export function CardForm({ mode, cardId, initial }: CardFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [question, setQuestion] = useState(initial?.question ?? '')
|
const [question, setQuestion] = useState(initial?.question ?? '')
|
||||||
const [response, setResponse] = useState(initial?.response ?? '')
|
const [response, setResponse] = useState(initial?.response ?? '')
|
||||||
const [theme, setTheme] = useState(initial?.theme ?? '')
|
const [theme, setTheme] = useState(initial?.theme ?? '')
|
||||||
|
const [tags, setTags] = useState<string[]>(initial?.tags ?? [])
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
const [availableTags, setAvailableTags] = useState<string[]>([])
|
||||||
|
const [tagError, setTagError] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
cardsApi
|
||||||
|
.tags()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setAvailableTags(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAvailableTags([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const suggestions = availableTags.filter(
|
||||||
|
(tag) => tag.includes(normalizeTag(tagInput)) && !tags.includes(tag),
|
||||||
|
)
|
||||||
|
|
||||||
|
function addTag(raw: string) {
|
||||||
|
const tag = normalizeTag(raw)
|
||||||
|
if (!tag) return
|
||||||
|
if (tags.includes(tag)) {
|
||||||
|
setTagError(`Le tag « ${tag} » est déjà présent`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tags.length >= MAX_TAGS) {
|
||||||
|
setTagError(`Maximum ${MAX_TAGS} tags par carte`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTags((current) => [...current, tag])
|
||||||
|
setTagInput('')
|
||||||
|
setTagError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
setTags((current) => current.filter((t) => t !== tag))
|
||||||
|
setTagError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addTag(tagInput)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setTagInput('')
|
||||||
|
setTagError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const input: CardInput = { question, response, theme }
|
const input: CardInput = { question, response, theme, tags }
|
||||||
try {
|
try {
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
const card = await cardsApi.create(input)
|
const card = await cardsApi.create(input)
|
||||||
@@ -83,6 +143,64 @@ export function CardForm({ mode, cardId, initial }: CardFormProps) {
|
|||||||
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
|
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-medium text-slate-700">
|
||||||
|
Tags ({tags.length}/{MAX_TAGS})
|
||||||
|
</span>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Retirer le tag ${tag}`}
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="text-slate-400 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id="tags"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTagInput(e.target.value)
|
||||||
|
setTagError(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleTagKeyDown}
|
||||||
|
placeholder="Saisis un tag puis Entrée (ex. git)"
|
||||||
|
aria-label="Ajouter un tag"
|
||||||
|
disabled={tags.length >= MAX_TAGS}
|
||||||
|
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{tagError && (
|
||||||
|
<p role="alert" className="mt-1 text-sm text-red-600">
|
||||||
|
{tagError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.slice(0, 6).map((tag) => (
|
||||||
|
<li key={tag}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addTag(tag)}
|
||||||
|
className="rounded-full border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
+ {tag}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const CARDS = [
|
|||||||
question: 'Cest quoi un commit ?',
|
question: 'Cest quoi un commit ?',
|
||||||
response: 'Un snapshot du projet',
|
response: 'Un snapshot du projet',
|
||||||
theme: 'git',
|
theme: 'git',
|
||||||
tags: [],
|
tags: ['commit', 'repo'],
|
||||||
createdAt: new Date('2026-01-01T08:00:00Z'),
|
createdAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
updatedAt: new Date('2026-01-01T08:00:00Z'),
|
updatedAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
},
|
},
|
||||||
@@ -68,6 +68,14 @@ it('affiche le thème de chaque carte', async () => {
|
|||||||
expect(within(item as HTMLElement).getByText('git')).toBeInTheDocument()
|
expect(within(item as HTMLElement).getByText('git')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche les tags de chaque carte', async () => {
|
||||||
|
vi.mocked(cardsApi.list).mockResolvedValue(CARDS)
|
||||||
|
render(<CardList />)
|
||||||
|
const item = (await screen.findByText('Cest quoi un commit ?')).closest('li')
|
||||||
|
expect(within(item as HTMLElement).getByText('#commit')).toBeInTheDocument()
|
||||||
|
expect(within(item as HTMLElement).getByText('#repo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('affiche un message si aucune carte', async () => {
|
it('affiche un message si aucune carte', async () => {
|
||||||
vi.mocked(cardsApi.list).mockResolvedValue([])
|
vi.mocked(cardsApi.list).mockResolvedValue([])
|
||||||
render(<CardList />)
|
render(<CardList />)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ListedCard {
|
|||||||
id: string
|
id: string
|
||||||
question: string
|
question: string
|
||||||
theme: string
|
theme: string
|
||||||
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardList() {
|
export function CardList() {
|
||||||
@@ -25,6 +26,7 @@ export function CardList() {
|
|||||||
id: card.id,
|
id: card.id,
|
||||||
question: card.question,
|
question: card.question,
|
||||||
theme: card.theme,
|
theme: card.theme,
|
||||||
|
tags: card.tags,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,21 @@ export function CardList() {
|
|||||||
href={`/cards/${card.id}`}
|
href={`/cards/${card.id}`}
|
||||||
className="flex items-center justify-between px-4 py-3 hover:bg-slate-50"
|
className="flex items-center justify-between px-4 py-3 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
<span className="font-medium">{card.question}</span>
|
<span className="font-medium">{card.question}</span>
|
||||||
|
{card.tags.length > 0 && (
|
||||||
|
<span className="flex flex-wrap gap-1.5">
|
||||||
|
{card.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
||||||
{card.theme}
|
{card.theme}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export interface CardRepository {
|
|||||||
findRandom(): Promise<Card | null>
|
findRandom(): Promise<Card | null>
|
||||||
findRandomByTheme(theme: string): Promise<Card | null>
|
findRandomByTheme(theme: string): Promise<Card | null>
|
||||||
findThemes(): Promise<string[]>
|
findThemes(): Promise<string[]>
|
||||||
|
findTags(): Promise<string[]>
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,41 @@ describe('createCardSchema', () => {
|
|||||||
it('rejette un thème manquant', () => {
|
it('rejette un thème manquant', () => {
|
||||||
expect(() => createCardSchema.parse({ question: 'Q', response: 'R' })).toThrow()
|
expect(() => createCardSchema.parse({ question: 'Q', response: 'R' })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('normalise les tags (trim + minuscules) et supprime les doublons', () => {
|
||||||
|
const result = createCardSchema.parse({
|
||||||
|
question: 'Q',
|
||||||
|
response: 'R',
|
||||||
|
theme: 'git',
|
||||||
|
tags: [' Git ', 'git', 'HOOK', 'hook'],
|
||||||
|
})
|
||||||
|
expect(result.tags).toEqual(['git', 'hook'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejette plus de 3 tags', () => {
|
||||||
|
expect(() =>
|
||||||
|
createCardSchema.parse({
|
||||||
|
question: 'Q',
|
||||||
|
response: 'R',
|
||||||
|
theme: 'git',
|
||||||
|
tags: ['a', 'b', 'c', 'd'],
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejette un tag vide ou trop long', () => {
|
||||||
|
expect(() =>
|
||||||
|
createCardSchema.parse({ question: 'Q', response: 'R', theme: 'git', tags: [' '] }),
|
||||||
|
).toThrow()
|
||||||
|
expect(() =>
|
||||||
|
createCardSchema.parse({
|
||||||
|
question: 'Q',
|
||||||
|
response: 'R',
|
||||||
|
theme: 'git',
|
||||||
|
tags: ['x'.repeat(31)],
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('updateCardSchema', () => {
|
describe('updateCardSchema', () => {
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const tagSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.min(1, 'Tag vide')
|
||||||
|
.max(30, 'Tag trop long (30 caractères max)')
|
||||||
|
|
||||||
export const createCardSchema = z.object({
|
export const createCardSchema = z.object({
|
||||||
question: z.string().trim().min(1, 'La question est requise').max(1000, 'Question trop longue'),
|
question: z.string().trim().min(1, 'La question est requise').max(1000, 'Question trop longue'),
|
||||||
response: z.string().trim().min(1, 'La réponse est requise').max(2000, 'Réponse trop longue'),
|
response: z.string().trim().min(1, 'La réponse est requise').max(2000, 'Réponse trop longue'),
|
||||||
theme: z.string().trim().min(1, 'Le thème est requis').max(50, 'Thème trop long'),
|
theme: z.string().trim().min(1, 'Le thème est requis').max(50, 'Thème trop long'),
|
||||||
tags: z.array(z.string().trim().min(1, 'Tag vide')).max(50, 'Trop de tags').optional(),
|
tags: z
|
||||||
|
.array(tagSchema)
|
||||||
|
.refine((tags) => new Set(tags).size <= 3, 'Maximum 3 tags par carte')
|
||||||
|
.transform((tags) => [...new Set(tags)])
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateCardSchema = createCardSchema.partial()
|
export const updateCardSchema = createCardSchema.partial()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
|
||||||
|
async function loadModule(env?: Record<string, string>) {
|
||||||
|
vi.resetModules()
|
||||||
|
delete process.env.API_BASE_URL
|
||||||
|
if (env) Object.assign(process.env, env)
|
||||||
|
return (await import('./api-client')) as typeof import('./api-client')
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = fetchMock as unknown as typeof fetch
|
||||||
|
fetchMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cardsApi (SSR / Node)', () => {
|
||||||
|
it("résout une URL absolue sur localhost:3000 en l'absence de window", async () => {
|
||||||
|
const { cardsApi: api } = await loadModule()
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await api.list()
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:3000/api/cards',
|
||||||
|
expect.objectContaining({ method: 'GET' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respecte API_BASE_URL si elle est définie', async () => {
|
||||||
|
const { cardsApi: api } = await loadModule({ API_BASE_URL: 'http://api:9999' })
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await api.list()
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://api:9999/api/cards',
|
||||||
|
expect.objectContaining({ method: 'GET' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -74,6 +74,12 @@ describe('cardsApi', () => {
|
|||||||
expectRequest('GET', '/api/themes')
|
expectRequest('GET', '/api/themes')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('tags() appelle GET /api/tags', async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse(['scope', 'fonction']))
|
||||||
|
await expect(cardsApi.tags()).resolves.toEqual(['scope', 'fonction'])
|
||||||
|
expectRequest('GET', '/api/tags')
|
||||||
|
})
|
||||||
|
|
||||||
it('lève ApiClientError avec le message de l API sur une erreur 404', async () => {
|
it('lève ApiClientError avec le message de l API sur une erreur 404', async () => {
|
||||||
fetchMock.mockResolvedValue(
|
fetchMock.mockResolvedValue(
|
||||||
jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404),
|
jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404),
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ interface ApiErrorPayload {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Côté serveur (SSR/standalone), une URL relative n'est pas résolvable :
|
||||||
|
// on cible la propre API de l'application (port interne 3000). Côté client,
|
||||||
|
// l'URL relative est résolue par le navigateur.
|
||||||
|
const API_BASE_URL =
|
||||||
|
typeof window === 'undefined' ? (process.env.API_BASE_URL ?? 'http://localhost:3000') : ''
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(path, {
|
res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: init?.method ?? 'GET',
|
method: init?.method ?? 'GET',
|
||||||
...init,
|
...init,
|
||||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
@@ -49,4 +55,5 @@ export const cardsApi = {
|
|||||||
random: (theme?: string) =>
|
random: (theme?: string) =>
|
||||||
request<Card>(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`),
|
request<Card>(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`),
|
||||||
themes: () => request<string[]>('/api/themes'),
|
themes: () => request<string[]>('/api/themes'),
|
||||||
|
tags: () => request<string[]>('/api/tags'),
|
||||||
}
|
}
|
||||||
@@ -141,4 +141,11 @@ describe('MongoCardRepository', () => {
|
|||||||
collection.distinct.mockResolvedValueOnce(['react', 'docker'])
|
collection.distinct.mockResolvedValueOnce(['react', 'docker'])
|
||||||
await expect(repo.findThemes()).resolves.toEqual(['docker', 'react'])
|
await expect(repo.findThemes()).resolves.toEqual(['docker', 'react'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('findTags renvoie les tags distincts triés', async () => {
|
||||||
|
const { repo, collection } = setup()
|
||||||
|
collection.distinct.mockResolvedValueOnce(['scope', 'docker'])
|
||||||
|
await expect(repo.findTags()).resolves.toEqual(['docker', 'scope'])
|
||||||
|
expect(collection.distinct).toHaveBeenCalledWith('tags')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -124,4 +124,9 @@ export class MongoCardRepository implements CardRepository {
|
|||||||
const themes: string[] = await this.collection.distinct('theme')
|
const themes: string[] = await this.collection.distinct('theme')
|
||||||
return themes.sort()
|
return themes.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findTags(): Promise<string[]> {
|
||||||
|
const tags = await this.collection.distinct('tags')
|
||||||
|
return tags.map((tag) => String(tag)).sort()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -154,4 +154,12 @@ describe('MysqlCardRepository', () => {
|
|||||||
query.mockResolvedValueOnce([[{ theme: 'docker' }, { theme: 'javascript' }]])
|
query.mockResolvedValueOnce([[{ theme: 'docker' }, { theme: 'javascript' }]])
|
||||||
await expect(repo.findThemes()).resolves.toEqual(['docker', 'javascript'])
|
await expect(repo.findThemes()).resolves.toEqual(['docker', 'javascript'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('findTags agrège les tags distincts et les trie', async () => {
|
||||||
|
const { repo, query } = setup()
|
||||||
|
query.mockResolvedValueOnce([
|
||||||
|
[{ tags: '["scope","git"]' }, { tags: null }, { tags: ['docker', 'git'] }],
|
||||||
|
])
|
||||||
|
await expect(repo.findTags()).resolves.toEqual(['docker', 'git', 'scope'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -147,4 +147,15 @@ export class MysqlCardRepository implements CardRepository {
|
|||||||
)
|
)
|
||||||
return rows.map((row) => row.theme as string)
|
return rows.map((row) => row.theme as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findTags(): Promise<string[]> {
|
||||||
|
const [rows] = await this.pool.query<RowDataPacket[]>('SELECT tags FROM cards')
|
||||||
|
const tags = new Set<string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const tag of this.parseTags(row.tags)) {
|
||||||
|
tags.add(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...tags].sort()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ function setup() {
|
|||||||
findRandom: vi.fn(),
|
findRandom: vi.fn(),
|
||||||
findRandomByTheme: vi.fn(),
|
findRandomByTheme: vi.fn(),
|
||||||
findThemes: vi.fn(),
|
findThemes: vi.fn(),
|
||||||
|
findTags: vi.fn(),
|
||||||
}
|
}
|
||||||
const service = new CardService(repository as unknown as CardRepository)
|
const service = new CardService(repository as unknown as CardRepository)
|
||||||
return { service, repository }
|
return { service, repository }
|
||||||
@@ -112,4 +113,10 @@ describe('CardService', () => {
|
|||||||
repository.findThemes.mockResolvedValueOnce(['docker', 'git'])
|
repository.findThemes.mockResolvedValueOnce(['docker', 'git'])
|
||||||
await expect(service.themes()).resolves.toEqual(['docker', 'git'])
|
await expect(service.themes()).resolves.toEqual(['docker', 'git'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('tags renvoie la liste des tags', async () => {
|
||||||
|
const { service, repository } = setup()
|
||||||
|
repository.findTags.mockResolvedValueOnce(['fonction', 'scope'])
|
||||||
|
await expect(service.tags()).resolves.toEqual(['fonction', 'scope'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -51,4 +51,8 @@ export class CardService {
|
|||||||
async themes(): Promise<string[]> {
|
async themes(): Promise<string[]> {
|
||||||
return this.repository.findThemes()
|
return this.repository.findThemes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async tags(): Promise<string[]> {
|
||||||
|
return this.repository.findTags()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user