feat(tags): gestion des tags dans le formulaire carte (max 3, suggestions, normalisation)
- schéma zod : tags normalisés (trim + minuscules + dédupe), 3 max par carte - couche générique : findTags (MySQL + Mongo), service tags() - API GET /api/tags + cardsApi.tags() pour les suggestions - formulaire : saisie + ajout (Entrée/bouton), suggestions des tags existants, chips supprimables, compteur n/3, blocage au-delà de 3 - tests : schémas, repositories, service, route, api-client, formulaire
This commit is contained in:
1 parent
31283ada26
commit
67569e2d16
18 files changed
+334
-8
No files matched your search
@@ -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([]) },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
+1
-2
@@ -39,8 +39,7 @@ const runtimeCaching: RuntimeCaching[] = [
|
|||||||
},
|
},
|
||||||
// Payloads RSC des navigations côté client : réseau d'abord
|
// Payloads RSC des navigations côté client : réseau d'abord
|
||||||
{
|
{
|
||||||
matcher: ({ request, sameOrigin }) =>
|
matcher: ({ request, sameOrigin }) => sameOrigin && request.headers.get('rsc') === '1',
|
||||||
sameOrigin && request.headers.get('rsc') === '1',
|
|
||||||
handler: new NetworkFirst({
|
handler: new NetworkFirst({
|
||||||
networkTimeoutSeconds: 3,
|
networkTimeoutSeconds: 3,
|
||||||
cacheName: 'rsc-network-first',
|
cacheName: 'rsc-network-first',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -55,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