Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/api/2024/featController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Feat from '@/models/2024/feat'
import SimpleController from '@/controllers/simpleController'

export default new SimpleController(Feat)
38 changes: 38 additions & 0 deletions src/models/2024/feat.ts
Original file line number Diff line number Diff line change
@@ -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<Feat2024>
const FeatModel = getModelForClass(Feat2024)

export default FeatModel
2 changes: 2 additions & 0 deletions src/routes/api/2024.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AbilityScoresHandler from './2024/abilityScores'
import FeatsHandler from './2024/feats'
import SkillsHandler from './2024/skills'

import express from 'express'
Expand All @@ -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
15 changes: 15 additions & 0 deletions src/routes/api/2024/feats.ts
Original file line number Diff line number Diff line change
@@ -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
158 changes: 158 additions & 0 deletions src/tests/controllers/api/2024/featController.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
44 changes: 44 additions & 0 deletions src/tests/factories/2024/feat.factory.ts
Original file line number Diff line number Diff line change
@@ -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<Prerequisite2024>(({ 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<Omit<Feat2024, '_id' | 'collectionName'>>(
({ 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()
}
}
)
73 changes: 73 additions & 0 deletions src/tests/integration/api/2024/feats.itest.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
Loading