diff --git a/README.md b/README.md index d69ce3614..dfe3ca993 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ You should get a response with the available endpoints for the root: "damage-types": "/api/2014/damage-types", "equipment-categories": "/api/2014/equipment-categories", "equipment": "/api/2014/equipment", + "feats": "/api/2014/feats", "features": "/api/2014/features", "languages": "/api/2014/languages", "magic-schools": "/api/2014/magic-schools", diff --git a/src/controllers/api/2024/featController.ts b/src/controllers/api/2024/featController.ts new file mode 100644 index 000000000..ec95fe74f --- /dev/null +++ b/src/controllers/api/2024/featController.ts @@ -0,0 +1,4 @@ +import Feat from '@/models/2024/feat' +import SimpleController from '@/controllers/simpleController' + +export default new SimpleController(Feat) diff --git a/src/models/2024/feat.ts b/src/models/2024/feat.ts new file mode 100644 index 000000000..688037369 --- /dev/null +++ b/src/models/2024/feat.ts @@ -0,0 +1,38 @@ +import { getModelForClass, prop } from '@typegoose/typegoose' +import { DocumentType } from '@typegoose/typegoose/lib/types' +import { APIReference } from '@/models/2024/common' +import { srdModelOptions } from '@/util/modelOptions' + +export class Prerequisite2024 { + @prop({ type: () => APIReference }) + public ability_score!: APIReference + + @prop({ required: true, index: true, type: () => Number }) + public minimum_score!: number +} + +@srdModelOptions('2024-feats') +export class Feat2024 { + @prop({ required: true, index: true, type: () => String }) + public index!: string + + @prop({ required: true, index: true, type: () => String }) + public name!: string + + @prop({ type: () => [Prerequisite2024] }) + public prerequisites!: Prerequisite2024[] + + @prop({ required: true, index: true, type: () => [String] }) + public desc!: string[] + + @prop({ required: true, index: true, type: () => String }) + public url!: string + + @prop({ required: true, index: true, type: () => String }) + public updated_at!: string +} + +export type FeatDocument = DocumentType +const FeatModel = getModelForClass(Feat2024) + +export default FeatModel diff --git a/src/routes/api/2024.ts b/src/routes/api/2024.ts index f65a192fd..455a9074a 100644 --- a/src/routes/api/2024.ts +++ b/src/routes/api/2024.ts @@ -1,4 +1,5 @@ import AbilityScoresHandler from './2024/abilityScores' +import FeatsHandler from './2024/feats' import SkillsHandler from './2024/skills' import express from 'express' @@ -9,6 +10,7 @@ const router = express.Router() router.get('/', index) router.use('/ability-scores', AbilityScoresHandler) +router.use('/feats', FeatsHandler) router.use('/skills', SkillsHandler) export default router diff --git a/src/routes/api/2024/feats.ts b/src/routes/api/2024/feats.ts new file mode 100644 index 000000000..affdc34b7 --- /dev/null +++ b/src/routes/api/2024/feats.ts @@ -0,0 +1,15 @@ +import * as express from 'express' + +import FeatController from '@/controllers/api/2024/featController' + +const router = express.Router() + +router.get('/', function (req, res, next) { + FeatController.index(req, res, next) +}) + +router.get('/:index', function (req, res, next) { + FeatController.show(req, res, next) +}) + +export default router diff --git a/src/tests/controllers/api/2024/featController.test.ts b/src/tests/controllers/api/2024/featController.test.ts new file mode 100644 index 000000000..9a0c4807f --- /dev/null +++ b/src/tests/controllers/api/2024/featController.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest' +import { createRequest, createResponse } from 'node-mocks-http' +import { mockNext as defaultMockNext } from '@/tests/support' + +import FeatModel from '@/models/2024/feat' // Use Model suffix +import FeatController from '@/controllers/api/2024/featController' +import { featFactory } from '@/tests/factories/2024/feat.factory' // Import factory +import { + generateUniqueDbUri, + setupIsolatedDatabase, + setupModelCleanup, + teardownIsolatedDatabase +} from '@/tests/support/db' + +const mockNext = vi.fn(defaultMockNext) + +// Generate URI for this test file +const dbUri = generateUniqueDbUri('feat') + +// Setup hooks using helpers +setupIsolatedDatabase(dbUri) +teardownIsolatedDatabase() +setupModelCleanup(FeatModel) + +describe('FeatController', () => { + describe('index', () => { + it('returns a list of feats', async () => { + // Arrange + const featsData = featFactory.buildList(3) + await FeatModel.insertMany(featsData) + const request = createRequest({ query: {} }) + const response = createResponse() + + // Act + await FeatController.index(request, response, mockNext) + + // Assert + expect(response.statusCode).toBe(200) + const responseData = JSON.parse(response._getData()) + expect(responseData.count).toBe(3) + expect(responseData.results).toHaveLength(3) + expect(responseData.results).toEqual( + expect.arrayContaining([ + // Index action returns index, name, url + expect.objectContaining({ + index: featsData[0].index, + name: featsData[0].name, + url: featsData[0].url + }), + expect.objectContaining({ + index: featsData[1].index, + name: featsData[1].name, + url: featsData[1].url + }), + expect.objectContaining({ + index: featsData[2].index, + name: featsData[2].name, + url: featsData[2].url + }) + ]) + ) + expect(mockNext).not.toHaveBeenCalled() + }) + + it('handles database errors during find', async () => { + // Arrange + const request = createRequest({ query: {} }) + const response = createResponse() + const error = new Error('Database find failed') + vi.spyOn(FeatModel, 'find').mockImplementationOnce(() => { + const query = { + select: vi.fn().mockReturnThis(), + sort: vi.fn().mockReturnThis(), + exec: vi.fn().mockRejectedValueOnce(error) + } as any + return query + }) + + // Act + await FeatController.index(request, response, mockNext) + + // Assert + expect(response.statusCode).toBe(200) // Controller passes error to next() + expect(response._getData()).toBe('') + expect(mockNext).toHaveBeenCalledOnce() + expect(mockNext).toHaveBeenCalledWith(error) + }) + + it('returns an empty list when no feats exist', async () => { + const request = createRequest({ query: {} }) + const response = createResponse() + await FeatController.index(request, response, mockNext) + expect(response.statusCode).toBe(200) + const responseData = JSON.parse(response._getData()) + expect(responseData.count).toBe(0) + expect(responseData.results).toEqual([]) + expect(mockNext).not.toHaveBeenCalled() + }) + }) + + describe('show', () => { + it('returns a single feat when found', async () => { + // Arrange + const featData = featFactory.build({ index: 'tough', name: 'Tough' }) + await FeatModel.insertMany([featData]) + const request = createRequest({ params: { index: 'tough' } }) + const response = createResponse() + + // Act + await FeatController.show(request, response, mockNext) + + // Assert + expect(response.statusCode).toBe(200) + const responseData = JSON.parse(response._getData()) + // Check specific fields returned by show + expect(responseData).toMatchObject({ + index: featData.index, + name: featData.name, + prerequisites: expect.any(Array), // Check structure if needed + desc: featData.desc, + url: featData.url + }) + expect(mockNext).not.toHaveBeenCalled() + }) + + it('calls next() when the feat is not found', async () => { + // Arrange + const request = createRequest({ params: { index: 'nonexistent' } }) + const response = createResponse() + + // Act + await FeatController.show(request, response, mockNext) + + // Assert + expect(response.statusCode).toBe(200) // Passes to next() + expect(response._getData()).toBe('') + expect(mockNext).toHaveBeenCalledOnce() + expect(mockNext).toHaveBeenCalledWith() + }) + + it('handles database errors during findOne', async () => { + // Arrange + const request = createRequest({ params: { index: 'tough' } }) + const response = createResponse() + const error = new Error('Database findOne failed') + vi.spyOn(FeatModel, 'findOne').mockRejectedValueOnce(error) + + // Act + await FeatController.show(request, response, mockNext) + + // Assert + expect(response.statusCode).toBe(200) // Passes to next() + expect(response._getData()).toBe('') + expect(mockNext).toHaveBeenCalledOnce() + expect(mockNext).toHaveBeenCalledWith(error) + }) + }) +}) diff --git a/src/tests/factories/2024/feat.factory.ts b/src/tests/factories/2024/feat.factory.ts new file mode 100644 index 000000000..f3196bb27 --- /dev/null +++ b/src/tests/factories/2024/feat.factory.ts @@ -0,0 +1,44 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' +import type { Feat2024, Prerequisite2024 } from '@/models/2024/feat' +import { apiReferenceFactory, createIndex, createUrl } from './common.factory' // Import common factory for APIReference and choiceFactory + +// --- Prerequisite Factory --- +const prerequisiteFactory = Factory.define(({ params }) => { + // Build dependency first + const builtApiRef = apiReferenceFactory.build(params.ability_score) + return { + // Explicitly construct the object + ability_score: { + index: builtApiRef.index, + name: builtApiRef.name, + url: builtApiRef.url + }, + minimum_score: params.minimum_score ?? faker.number.int({ min: 8, max: 15 }) + } +}) + +// --- Feat Factory --- +export const featFactory = Factory.define>( + ({ sequence, params }) => { + const name = params.name ?? `${faker.word.adjective()} Feat ${sequence}` + const index = params.index ?? createIndex(name) + + // Explicitly build a list of Prerequisite objects + const prerequisites = + params.prerequisites ?? prerequisiteFactory.buildList(faker.number.int({ min: 0, max: 1 })) + + return { + index, + name, + prerequisites: prerequisites.map((p) => ({ + // Ensure the array type is correct + ability_score: p.ability_score, + minimum_score: p.minimum_score + })), + desc: params.desc ?? [faker.lorem.paragraph()], + url: params.url ?? createUrl('feats', index), + updated_at: params.updated_at ?? faker.date.past().toISOString() + } + } +) diff --git a/src/tests/integration/api/2024/feats.itest.ts b/src/tests/integration/api/2024/feats.itest.ts new file mode 100644 index 000000000..994e856fd --- /dev/null +++ b/src/tests/integration/api/2024/feats.itest.ts @@ -0,0 +1,73 @@ +import { mongodbUri, redisClient } from '@/util' + +import { Application } from 'express' +import { afterEach, afterAll, beforeAll, describe, it, expect, vi } from 'vitest' +import createApp from '@/server' + +import mongoose from 'mongoose' +import request from 'supertest' + +let app: Application +let server: any + +afterEach(() => { + vi.clearAllMocks() +}) + +beforeAll(async () => { + await mongoose.connect(mongodbUri) + await redisClient.connect() + app = await createApp() + server = app.listen() // Start the server and store the instance +}) + +afterAll(async () => { + await mongoose.disconnect() + await redisClient.quit() + server.close() +}) + +describe('/api/2024/feats', () => { + it('should list feats', async () => { + const res = await request(app).get('/api/2024/feats') + expect(res.statusCode).toEqual(200) + expect(res.body.results.length).not.toEqual(0) + }) + + describe('with name query', () => { + it('returns the named object', async () => { + const indexRes = await request(app).get('/api/2024/feats') + const name = indexRes.body.results[0].name + const res = await request(app).get(`/api/2024/feats?name=${name}`) + expect(res.statusCode).toEqual(200) + expect(res.body.results[0].name).toEqual(name) + }) + + it('is case insensitive', async () => { + const indexRes = await request(app).get('/api/2024/feats') + const name = indexRes.body.results[0].name + const queryName = name.toLowerCase() + const res = await request(app).get(`/api/2024/feats?name=${queryName}`) + expect(res.statusCode).toEqual(200) + expect(res.body.results[0].name).toEqual(name) + }) + }) + + describe('/api/2024/feats/:index', () => { + it('should return one object', async () => { + const indexRes = await request(app).get('/api/2024/feats') + const index = indexRes.body.results[0].index + const showRes = await request(app).get(`/api/2024/feats/${index}`) + expect(showRes.statusCode).toEqual(200) + expect(showRes.body.index).toEqual(index) + }) + + describe('with an invalid index', () => { + it('should return 404', async () => { + const invalidIndex = 'invalid-index' + const showRes = await request(app).get(`/api/2024/feats/${invalidIndex}`) + expect(showRes.statusCode).toEqual(404) + }) + }) + }) +})