diff --git a/services/platform-service/src/modules/comments/routes.test.ts b/services/platform-service/src/modules/comments/routes.test.ts new file mode 100644 index 00000000..a10b190c --- /dev/null +++ b/services/platform-service/src/modules/comments/routes.test.ts @@ -0,0 +1,182 @@ +/** + * Route-level tests for comments module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const commentRepoMock = { + listByItem: vi.fn(), + create: vi.fn(), + getById: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +}; + +const itemRepoMock = { + getById: vi.fn(), + incrementCommentCount: vi.fn(), +}; + +const authMock = { + extractAuth: vi.fn(), +}; + +vi.mock('./repository.js', () => commentRepoMock); +vi.mock('../items/repository.js', () => itemRepoMock); +vi.mock('../../lib/auth.js', () => authMock); + +const baseItem = { + id: 'item_1', + productId: 'lysnrai', + title: 'Feature request', +}; + +const baseComment = { + id: 'cmt_1', + itemId: 'item_1', + productId: 'lysnrai', + authorId: 'user_1', + authorEmail: 'user1@example.com', + body: 'Looks good', + createdAt: '2026-02-16T00:00:00Z', + updatedAt: '2026-02-16T00:00:00Z', +}; + +describe('commentRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authMock.extractAuth.mockResolvedValue({ sub: 'user_1', email: 'user1@example.com', role: 'user' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /items/:itemId/comments returns list', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + commentRepoMock.listByItem.mockResolvedValue([baseComment]); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/items/item_1/comments' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.comments).toHaveLength(1); + expect(data.count).toBe(1); + }); + + it('POST /items/:itemId/comments creates comment', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + commentRepoMock.create.mockResolvedValue(baseComment); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/items/item_1/comments', + payload: { body: 'Looks good' }, + }); + + expect(res.statusCode).toBe(201); + expect(itemRepoMock.incrementCommentCount).toHaveBeenCalledWith('item_1', 1); + }); + + it('POST /items/:itemId/comments returns 400 for invalid payload', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/items/item_1/comments', + payload: { body: '' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('PUT /items/:itemId/comments/:id updates comment for author', async () => { + commentRepoMock.getById.mockResolvedValue(baseComment); + commentRepoMock.update.mockResolvedValue({ ...baseComment, body: 'Updated' }); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'PUT', + url: '/api/items/item_1/comments/cmt_1', + payload: { body: 'Updated' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.body).toBe('Updated'); + }); + + it('PUT /items/:itemId/comments/:id returns 403 for non-author user', async () => { + authMock.extractAuth.mockResolvedValue({ sub: 'user_2', email: 'user2@example.com', role: 'user' }); + commentRepoMock.getById.mockResolvedValue(baseComment); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'PUT', + url: '/api/items/item_1/comments/cmt_1', + payload: { body: 'Updated' }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('DELETE /items/:itemId/comments/:id deletes for author', async () => { + commentRepoMock.getById.mockResolvedValue(baseComment); + commentRepoMock.remove.mockResolvedValue(undefined); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/items/item_1/comments/cmt_1' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.success).toBe(true); + expect(itemRepoMock.incrementCommentCount).toHaveBeenCalledWith('item_1', -1); + }); + + it('DELETE /items/:itemId/comments/:id allows admin to delete', async () => { + authMock.extractAuth.mockResolvedValue({ sub: 'admin_1', email: 'admin@example.com', role: 'admin' }); + commentRepoMock.getById.mockResolvedValue(baseComment); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/items/item_1/comments/cmt_1' }); + + expect(res.statusCode).toBe(200); + }); + + it('DELETE /items/:itemId/comments/:id returns 404 when comment missing', async () => { + commentRepoMock.getById.mockResolvedValue(null); + + const { commentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(commentRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/items/item_1/comments/missing' }); + + expect(res.statusCode).toBe(404); + }); +});