diff --git a/src/config/card-repository-factory.test.ts b/src/config/card-repository-factory.test.ts new file mode 100644 index 0000000..3119d0b --- /dev/null +++ b/src/config/card-repository-factory.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest' +import { MongoCardRepository } from '../repositories/mongo-card-repository' +import { MysqlCardRepository } from '../repositories/mysql-card-repository' +import { getCardRepository } from './card-repository-factory' + +describe('getCardRepository', () => { + const original = process.env.DB_IMPL + + afterEach(() => { + if (original === undefined) { + delete process.env.DB_IMPL + } else { + process.env.DB_IMPL = original + } + }) + + it('instancie MysqlCardRepository par défaut', () => { + delete process.env.DB_IMPL + expect(getCardRepository()).toBeInstanceOf(MysqlCardRepository) + }) + + it('instancie MysqlCardRepository si DB_IMPL=mysql', () => { + process.env.DB_IMPL = 'mysql' + expect(getCardRepository()).toBeInstanceOf(MysqlCardRepository) + }) + + it('instancie MongoCardRepository si DB_IMPL=mongo', () => { + process.env.DB_IMPL = 'mongo' + expect(getCardRepository()).toBeInstanceOf(MongoCardRepository) + }) +}) \ No newline at end of file diff --git a/src/repositories/mongo-card-repository.test.ts b/src/repositories/mongo-card-repository.test.ts new file mode 100644 index 0000000..86f940d --- /dev/null +++ b/src/repositories/mongo-card-repository.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest' +import type { Collection } from 'mongodb' +import { MongoCardRepository } 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'), +} + +function cursorOf(docs: unknown[]) { + return { toArray: vi.fn().mockResolvedValue(docs) } +} + +function setup() { + const collection = { + find: vi.fn(), + findOne: vi.fn(), + insertOne: vi.fn(), + updateOne: vi.fn(), + deleteOne: vi.fn(), + aggregate: vi.fn(), + distinct: vi.fn(), + } as unknown as Collection + const repo = new MongoCardRepository(collection) + return { repo, collection } +} + +describe('MongoCardRepository', () => { + it('findAll renvoie toutes les cartes mappées', async () => { + const { repo, collection } = setup() + collection.find.mockReturnValue(cursorOf([DOC]) as any) + 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([]) as any) + 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: '' } as any) + const card = await repo.create({ + question: 'Que retourne typeof null ?', + response: 'object', + theme: 'javascript', + }) + const [doc] = collection.insertOne.mock.calls[0] + expect(doc.c_id).toBe(card.id) + expect(doc.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 } as any) + 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] + expect(filter).toEqual({ c_id: DOC.c_id }) + expect(set.$set.response).toBe('Nouvelle') + expect(set.$set.updatedAt).toBeInstanceOf(Date) + }) + + it('update renvoie null si aucune carte ne correspond', async () => { + const { repo, collection } = setup() + collection.updateOne.mockResolvedValueOnce({ matchedCount: 0 } as any) + 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 } as any) + 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 } as any) + 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]) as any) + 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([]) as any) + await expect(repo.findRandom()).resolves.toBeNull() + }) + + it('findRandomByTheme filtre par thème', async () => { + const { repo, collection } = setup() + collection.aggregate.mockReturnValueOnce(cursorOf([DOC]) as any) + const card = await repo.findRandomByTheme('javascript') + expect(card?.id).toBe(DOC.c_id) + const stages = collection.aggregate.mock.calls[0][0] + expect(stages[0]).toEqual({ $match: { theme: 'javascript' } }) + collection.aggregate.mockReturnValueOnce(cursorOf([]) as any) + 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']) + }) +}) \ No newline at end of file diff --git a/src/repositories/mysql-card-repository.test.ts b/src/repositories/mysql-card-repository.test.ts new file mode 100644 index 0000000..3867999 --- /dev/null +++ b/src/repositories/mysql-card-repository.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest' +import type { Pool } from 'mysql2/promise' +import type { Card } from '../domain/card' +import { MysqlCardRepository } from './mysql-card-repository' + +const ROW = { + 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'), +} + +const EXPECTED_CARD: Card = { + id: ROW.id, + question: ROW.question, + response: ROW.response, + theme: ROW.theme, + tags: ['scope'], + createdAt: ROW.createdAt, + updatedAt: ROW.updatedAt, +} + +function setup() { + const query = vi.fn() + const pool = { query } as unknown as Pool + return { repo: new MysqlCardRepository(pool), query } +} + +describe('MysqlCardRepository', () => { + it('findAll renvoie toutes les cartes mappées', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([ + [ROW, { ...ROW, id: '10000000-0000-4000-8000-000000000002', tags: null }], + ]) + const cards = await repo.findAll() + expect(cards).toHaveLength(2) + expect(cards[0]).toEqual(EXPECTED_CARD) + expect(cards[1].tags).toEqual([]) + }) + + it('findAll renvoie un tableau vide si aucune carte', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[]]) + await expect(repo.findAll()).resolves.toEqual([]) + }) + + it('findById renvoie la carte correspondante', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[ROW]]) + await expect(repo.findById(ROW.id)).resolves.toEqual(EXPECTED_CARD) + expect(query.mock.calls[0][1]).toEqual([ROW.id]) + }) + + it('findById renvoie null si la carte est inconnue', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[]]) + await expect(repo.findById('inconnu')).resolves.toBeNull() + }) + + it('create insère une carte et renvoie l entité complète', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([{ affectedRows: 1 }]) + const card = await repo.create({ + question: 'Que retourne typeof null ?', + response: 'object', + theme: 'javascript', + tags: ['types'], + }) + expect(card.id).toHaveLength(36) + expect(card.tags).toEqual(['types']) + expect(card.createdAt).toBeInstanceOf(Date) + const [params] = query.mock.calls[0][1] as unknown[] + expect(params).toBe(card.id) + expect(query.mock.calls[0][0]).toContain('INSERT INTO cards') + }) + + it('create utilise un tableau de tags vide si non fourni', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([{ affectedRows: 1 }]) + const card = await repo.create({ question: 'Q ?', response: 'R', theme: 'git' }) + expect(card.tags).toEqual([]) + expect(query.mock.calls[0][1]).toContain('[]') + }) + + it('update modifie les champs fournis et renvoie la carte', async () => { + const { repo, query } = setup() + const updated = { ...ROW, response: 'Nouvelle réponse' } + query.mockResolvedValueOnce([{ affectedRows: 1 }]).mockResolvedValueOnce([[updated]]) + const card = await repo.update(ROW.id, { response: 'Nouvelle réponse' }) + expect(card?.response).toBe('Nouvelle réponse') + const [updateCall] = query.mock.calls + expect(updateCall[0]).toContain('UPDATE cards SET') + expect(updateCall[1]).toContain('Nouvelle réponse') + }) + + it('update sérialise les tags en JSON', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([{ affectedRows: 1 }]).mockResolvedValueOnce([[ROW]]) + await repo.update(ROW.id, { tags: ['a', 'b'] }) + expect(query.mock.calls[0][1]).toContain('["a","b"]') + }) + + it('update sans champs ne lance pas d UPDATE', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[ROW]]) + await expect(repo.update(ROW.id, {})).resolves.toEqual(EXPECTED_CARD) + expect(query).toHaveBeenCalledTimes(1) + expect(query.mock.calls[0][0]).toContain('SELECT') + }) + + it('update renvoie null si la carte est inconnue', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([{ affectedRows: 1 }]).mockResolvedValueOnce([[]]) + await expect(repo.update('inconnu', { question: 'X' })).resolves.toBeNull() + }) + + it('delete renvoie true si une ligne est supprimée', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([{ affectedRows: 1 }]) + await expect(repo.delete(ROW.id)).resolves.toBe(true) + expect(query.mock.calls[0][1]).toEqual([ROW.id]) + }) + + it('delete renvoie false si aucune ligne ne correspond', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([{ affectedRows: 0 }]) + await expect(repo.delete('inconnu')).resolves.toBe(false) + }) + + it('findRandom renvoie une carte au hasard ou null', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[ROW]]) + await expect(repo.findRandom()).resolves.toEqual(EXPECTED_CARD) + query.mockResolvedValueOnce([[]]) + await expect(repo.findRandom()).resolves.toBeNull() + }) + + it('findRandomByTheme filtre par thème', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[ROW]]) + const card = await repo.findRandomByTheme('javascript') + expect(card).toEqual(EXPECTED_CARD) + const [sql, params] = query.mock.calls[0] + expect(sql).toContain('WHERE theme = ?') + expect(params).toEqual(['javascript']) + }) + + it('findThemes renvoie les thèmes distincts', async () => { + const { repo, query } = setup() + query.mockResolvedValueOnce([[{ theme: 'docker' }, { theme: 'javascript' }]]) + await expect(repo.findThemes()).resolves.toEqual(['docker', 'javascript']) + }) +}) \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts index 6f13ed5..d2412e3 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -4,16 +4,18 @@ import { afterEach } from 'vitest' afterEach(() => cleanup()) -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }), -}) +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }) +} \ No newline at end of file