Files
flashcards/src/repositories/mongo-card-repository.test.ts
T
devcodetools 67569e2d16 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
2026-09-22 10:18:13 +02:00

152 lines
5.8 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import type { Collection } from 'mongodb'
import { MongoCardRepository, type CardDocument } from './mongo-card-repository'
const DOC = {
c_id: '10000000-0000-4000-8000-000000000001',
question: "Qu'est-ce qu'une closure ?",
response: 'Une fonction qui conserve la portée de sa définition.',
theme: 'javascript',
tags: ['scope'],
createdAt: new Date('2026-01-01T08:00:00Z'),
updatedAt: new Date('2026-01-02T09:00:00Z'),
}
interface CollectionMock {
find: ReturnType<typeof vi.fn>
findOne: ReturnType<typeof vi.fn>
insertOne: ReturnType<typeof vi.fn>
updateOne: ReturnType<typeof vi.fn>
deleteOne: ReturnType<typeof vi.fn>
aggregate: ReturnType<typeof vi.fn>
distinct: ReturnType<typeof vi.fn>
}
function cursorOf(docs: unknown[]) {
return { toArray: vi.fn().mockResolvedValue(docs) }
}
function setup() {
const collection: CollectionMock = {
find: vi.fn(),
findOne: vi.fn(),
insertOne: vi.fn(),
updateOne: vi.fn(),
deleteOne: vi.fn(),
aggregate: vi.fn(),
distinct: vi.fn(),
}
const repo = new MongoCardRepository(collection as unknown as Collection<CardDocument>)
return { repo, collection }
}
describe('MongoCardRepository', () => {
it('findAll renvoie toutes les cartes mappées', async () => {
const { repo, collection } = setup()
collection.find.mockReturnValue(cursorOf([DOC]))
const cards = await repo.findAll()
expect(cards).toHaveLength(1)
expect(cards[0].id).toBe(DOC.c_id)
expect(cards[0].tags).toEqual(['scope'])
})
it('findAll renvoie un tableau vide si aucune carte', async () => {
const { repo, collection } = setup()
collection.find.mockReturnValue(cursorOf([]))
await expect(repo.findAll()).resolves.toEqual([])
})
it('findById renvoie la carte ou null', async () => {
const { repo, collection } = setup()
collection.findOne.mockResolvedValueOnce(DOC)
const card = await repo.findById(DOC.c_id)
expect(card?.id).toBe(DOC.c_id)
expect(collection.findOne).toHaveBeenCalledWith({ c_id: DOC.c_id }, expect.anything())
collection.findOne.mockResolvedValueOnce(null)
await expect(repo.findById('inconnu')).resolves.toBeNull()
})
it('create insère un document avec c_id et renvoie l entité', async () => {
const { repo, collection } = setup()
collection.insertOne.mockResolvedValueOnce({ insertedId: '' })
const card = await repo.create({
question: 'Que retourne typeof null ?',
response: 'object',
theme: 'javascript',
})
const [doc] = collection.insertOne.mock.calls[0] as unknown[]
const inserted = doc as Record<string, unknown>
expect(inserted.c_id).toBe(card.id)
expect(inserted.tags).toEqual([])
expect(card.tags).toEqual([])
expect(card.createdAt).toBeInstanceOf(Date)
})
it('update modifie les champs fournis et renvoie la carte', async () => {
const { repo, collection } = setup()
collection.updateOne.mockResolvedValueOnce({ matchedCount: 1 })
collection.findOne.mockResolvedValueOnce({ ...DOC, response: 'Nouvelle' })
const card = await repo.update(DOC.c_id, { response: 'Nouvelle' })
expect(card?.response).toBe('Nouvelle')
const [filter, set] = collection.updateOne.mock.calls[0] as unknown[]
const setDoc = set as Record<string, { response?: string; updatedAt?: Date }>
expect(filter).toEqual({ c_id: DOC.c_id })
expect(setDoc.$set.response).toBe('Nouvelle')
expect(setDoc.$set.updatedAt).toBeInstanceOf(Date)
})
it('update renvoie null si aucune carte ne correspond', async () => {
const { repo, collection } = setup()
collection.updateOne.mockResolvedValueOnce({ matchedCount: 0 })
await expect(repo.update('inconnu', { question: 'X' })).resolves.toBeNull()
})
it('delete renvoie true si un document est supprimé', async () => {
const { repo, collection } = setup()
collection.deleteOne.mockResolvedValueOnce({ deletedCount: 1 })
await expect(repo.delete(DOC.c_id)).resolves.toBe(true)
expect(collection.deleteOne).toHaveBeenCalledWith({ c_id: DOC.c_id })
})
it('delete renvoie false si aucun document ne correspond', async () => {
const { repo, collection } = setup()
collection.deleteOne.mockResolvedValueOnce({ deletedCount: 0 })
await expect(repo.delete('inconnu')).resolves.toBe(false)
})
it('findRandom renvoie une carte au hasard ou null', async () => {
const { repo, collection } = setup()
collection.aggregate.mockReturnValueOnce(cursorOf([DOC]))
const card = await repo.findRandom()
expect(card?.id).toBe(DOC.c_id)
expect(collection.aggregate.mock.calls[0][0]).toContainEqual({ $sample: { size: 1 } })
collection.aggregate.mockReturnValueOnce(cursorOf([]))
await expect(repo.findRandom()).resolves.toBeNull()
})
it('findRandomByTheme filtre par thème', async () => {
const { repo, collection } = setup()
collection.aggregate.mockReturnValueOnce(cursorOf([DOC]))
const card = await repo.findRandomByTheme('javascript')
expect(card?.id).toBe(DOC.c_id)
const stages = collection.aggregate.mock.calls[0][0] as unknown[]
expect(stages[0]).toEqual({ $match: { theme: 'javascript' } })
collection.aggregate.mockReturnValueOnce(cursorOf([]))
await expect(repo.findRandomByTheme('react')).resolves.toBeNull()
})
it('findThemes renvoie les thèmes distincts triés', async () => {
const { repo, collection } = setup()
collection.distinct.mockResolvedValueOnce(['react', 'docker'])
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')
})
})