Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/controllers/api/2024/magicItemController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import SimpleController from '@/controllers/simpleController'
import MagicItemModel from '@/models/2024/magicItem'

export default new SimpleController(MagicItemModel)
2 changes: 2 additions & 0 deletions src/graphql/2024/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ContentFieldResolver, EquipmentResolver, ToolResolver } from './equipme
import { EquipmentCategoryResolver } from './equipmentCategory/resolver'
import { FeatResolver } from './feat/resolver'
import { LanguageResolver } from './language/resolver'
import { MagicItemResolver } from './magicItem/resolver'
import { MagicSchoolResolver } from './magicSchool/resolver'
import { ProficiencyResolver } from './proficiency/resolver'
import { SkillResolver } from './skill/resolver'
Expand All @@ -27,6 +28,7 @@ const collectionResolvers = [
EquipmentCategoryResolver,
FeatResolver,
LanguageResolver,
MagicItemResolver,
MagicSchoolResolver,
ProficiencyResolver,
SkillResolver,
Expand Down
73 changes: 73 additions & 0 deletions src/graphql/2024/resolvers/magicItem/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ArgsType, Field, InputType, registerEnumType } from 'type-graphql'
import { z } from 'zod'

import {
BaseFilterArgs,
BaseFilterArgsSchema,
BaseIndexArgsSchema,
BaseOrderInterface
} from '@/graphql/common/args'
import { OrderByDirection } from '@/graphql/common/enums'

export enum MagicItemOrderField {
NAME = 'name'
}

export const MAGIC_ITEM_SORT_FIELD_MAP: Record<MagicItemOrderField, string> = {
[MagicItemOrderField.NAME]: 'name'
}

registerEnumType(MagicItemOrderField, {
name: 'MagicItemOrderField',
description: 'Fields to sort Magic Items by'
})

@InputType()
export class MagicItemOrder implements BaseOrderInterface<MagicItemOrderField> {
@Field(() => MagicItemOrderField)
by!: MagicItemOrderField

@Field(() => OrderByDirection)
direction!: OrderByDirection

@Field(() => MagicItemOrder, { nullable: true })
then_by?: MagicItemOrder
}

export const MagicItemOrderSchema: z.ZodType<MagicItemOrder> = z.lazy(() =>
z.object({
by: z.nativeEnum(MagicItemOrderField),
direction: z.nativeEnum(OrderByDirection),
then_by: MagicItemOrderSchema.optional()
})
)

export const MagicItemArgsSchema = z.object({
...BaseFilterArgsSchema.shape,
equipment_category: z.array(z.string()).optional(),
rarity: z.array(z.string()).optional(),
order: MagicItemOrderSchema.optional()
})

export const MagicItemIndexArgsSchema = BaseIndexArgsSchema

@ArgsType()
export class MagicItemArgs extends BaseFilterArgs {
@Field(() => [String], {
nullable: true,
description: 'Filter by equipment category index (e.g., ["wondrous-items", "armor"])'
})
equipment_category?: string[]

@Field(() => [String], {
nullable: true,
description: 'Filter by rarity name (e.g., ["Common", "Rare"])'
})
rarity?: string[]

@Field(() => MagicItemOrder, {
nullable: true,
description: 'Specify sorting order for magic items.'
})
order?: MagicItemOrder
}
81 changes: 81 additions & 0 deletions src/graphql/2024/resolvers/magicItem/resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Arg, Args, FieldResolver, Query, Resolver, Root } from 'type-graphql'

import { buildSortPipeline } from '@/graphql/common/args'
import { resolveSingleReference, resolveMultipleReferences } from '@/graphql/utils/resolvers'
import EquipmentCategoryModel, { EquipmentCategory2024 } from '@/models/2024/equipmentCategory'
import MagicItemModel, { MagicItem2024 } from '@/models/2024/magicItem'
import { escapeRegExp } from '@/util'

import {
MAGIC_ITEM_SORT_FIELD_MAP,
MagicItemArgs,
MagicItemArgsSchema,
MagicItemIndexArgsSchema,
MagicItemOrderField
} from './args'

@Resolver(MagicItem2024)
export class MagicItemResolver {
@Query(() => [MagicItem2024], {
description: 'Gets all magic items, optionally filtered by name, equipment category, or rarity.'
})
async magicItems(@Args(() => MagicItemArgs) args: MagicItemArgs): Promise<MagicItem2024[]> {
const validatedArgs = MagicItemArgsSchema.parse(args)
const query = MagicItemModel.find()
const filters: any[] = []

if (validatedArgs.name != null && validatedArgs.name !== '') {
filters.push({ name: { $regex: new RegExp(escapeRegExp(validatedArgs.name), 'i') } })
}

if (validatedArgs.equipment_category && validatedArgs.equipment_category.length > 0) {
filters.push({ 'equipment_category.index': { $in: validatedArgs.equipment_category } })
}

if (validatedArgs.rarity && validatedArgs.rarity.length > 0) {
filters.push({ 'rarity.name': { $in: validatedArgs.rarity } })
}

if (filters.length > 0) {
query.where({ $and: filters })
}

const sortQuery = buildSortPipeline<MagicItemOrderField>({
order: validatedArgs.order,
sortFieldMap: MAGIC_ITEM_SORT_FIELD_MAP,
defaultSortField: MagicItemOrderField.NAME
})

if (Object.keys(sortQuery).length > 0) {
query.sort(sortQuery)
}

if (validatedArgs.skip) {
query.skip(validatedArgs.skip)
}
if (validatedArgs.limit) {
query.limit(validatedArgs.limit)
}

return query.lean()
}

@Query(() => MagicItem2024, {
nullable: true,
description: 'Gets a single magic item by index.'
})
async magicItem(@Arg('index', () => String) indexInput: string): Promise<MagicItem2024 | null> {
const { index } = MagicItemIndexArgsSchema.parse({ index: indexInput })
return MagicItemModel.findOne({ index }).lean()
}

@FieldResolver(() => EquipmentCategory2024)
async equipment_category(@Root() magicItem: MagicItem2024): Promise<EquipmentCategory2024 | null> {
return resolveSingleReference(magicItem.equipment_category, EquipmentCategoryModel)
}

@FieldResolver(() => [MagicItem2024])
async variants(@Root() magicItem: MagicItem2024): Promise<MagicItem2024[]> {
return resolveMultipleReferences(magicItem.variants, MagicItemModel)
}
}
87 changes: 87 additions & 0 deletions src/models/2024/magicItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { getModelForClass, prop } from '@typegoose/typegoose'
import { DocumentType } from '@typegoose/typegoose/lib/types'
import { Field, ObjectType } from 'type-graphql'

import { APIReference } from '@/models/common/apiReference'
import { srdModelOptions } from '@/util/modelOptions'

import { EquipmentCategory2024 } from './equipmentCategory'

@ObjectType({ description: 'The rarity level of a 2024 magic item.' })
export class Rarity2024 {
@Field(() => String, {
description: 'The name of the rarity level (e.g., Common, Uncommon, Rare).'
})
@prop({ required: true, index: true, type: () => String })
public name!: string
}

@ObjectType({ description: 'An item imbued with magical properties in D&D 5e 2024.' })
@srdModelOptions('2024-magic-items')
export class MagicItem2024 {
@Field(() => String, {
description: 'The unique identifier for this magic item (e.g., bag-of-holding).'
})
@prop({ required: true, index: true, type: () => String })
public index!: string

@Field(() => String, { description: 'The name of the magic item.' })
@prop({ required: true, index: true, type: () => String })
public name!: string

@Field(() => String, { description: 'A description of the magic item.' })
@prop({ required: true, type: () => String })
public desc!: string

@Field(() => String, { nullable: true, description: 'URL of an image for the magic item.' })
@prop({ type: () => String, index: true })
public image?: string

@Field(() => EquipmentCategory2024, {
description: 'The category of equipment this magic item belongs to.'
})
@prop({ type: () => APIReference, index: true })
public equipment_category!: APIReference

@Field(() => Boolean, {
description: 'Whether this magic item requires attunement.'
})
@prop({ required: true, index: true, type: () => Boolean })
public attunement!: boolean

@Field(() => Boolean, {
description: 'Indicates if this magic item is a variant of another item.'
})
@prop({ required: true, index: true, type: () => Boolean })
public variant!: boolean

@Field(() => [MagicItem2024], {
nullable: true,
description: 'Other magic items that are variants of this item.'
})
@prop({ type: () => [APIReference], index: true })
public variants!: APIReference[]

@Field(() => Rarity2024, { description: 'The rarity of the magic item.' })
@prop({ required: true, index: true, type: () => Rarity2024 })
public rarity!: Rarity2024

@Field(() => String, {
nullable: true,
description: 'Class restriction for attunement (e.g., "by a wizard").'
})
@prop({ type: () => String, index: true })
public limited_to?: string

@prop({ required: true, index: true, type: () => String })
public url!: string

@Field(() => String, { description: 'Timestamp of the last update.' })
@prop({ required: true, index: true, type: () => String })
public updated_at!: string
}

export type MagicItemDocument = DocumentType<MagicItem2024>
const MagicItemModel = getModelForClass(MagicItem2024)

export default MagicItemModel
2 changes: 2 additions & 0 deletions src/routes/api/2024.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import EquipmentHandler from './2024/equipment'
import EquipmentCategoriesHandler from './2024/equipmentCategories'
import FeatsHandler from './2024/feats'
import LanguagesHandler from './2024/languages'
import MagicItemsHandler from './2024/magicItems'
import MagicSchoolsHandler from './2024/magicSchools'
import ProficienciesHandler from './2024/proficiencies'
import SkillsHandler from './2024/skills'
Expand All @@ -35,6 +36,7 @@ router.use('/equipment', EquipmentHandler)
router.use('/equipment-categories', EquipmentCategoriesHandler)
router.use('/feats', FeatsHandler)
router.use('/languages', LanguagesHandler)
router.use('/magic-items', MagicItemsHandler)
router.use('/magic-schools', MagicSchoolsHandler)
router.use('/proficiencies', ProficienciesHandler)
router.use('/skills', SkillsHandler)
Expand Down
15 changes: 15 additions & 0 deletions src/routes/api/2024/magicItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as express from 'express'

import MagicItemController from '@/controllers/api/2024/magicItemController'

const router = express.Router()

router.get('/', function (req, res, next) {
MagicItemController.index(req, res, next)
})

router.get('/:index', function (req, res, next) {
MagicItemController.show(req, res, next)
})

export default router
87 changes: 87 additions & 0 deletions src/tests/controllers/api/2024/MagicItemController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { createRequest, createResponse } from 'node-mocks-http'
import { describe, expect, it, vi } from 'vitest'

import MagicItemController from '@/controllers/api/2024/magicItemController'
import MagicItemModel from '@/models/2024/magicItem'
import { magicItemFactory } from '@/tests/factories/2024/magicItem.factory'
import { mockNext as defaultMockNext } from '@/tests/support'
import {
generateUniqueDbUri,
setupIsolatedDatabase,
setupModelCleanup,
teardownIsolatedDatabase
} from '@/tests/support/db'

const mockNext = vi.fn(defaultMockNext)

const dbUri = generateUniqueDbUri('magic-item')

setupIsolatedDatabase(dbUri)
teardownIsolatedDatabase()
setupModelCleanup(MagicItemModel)

describe('MagicItemController', () => {
describe('index', () => {
it('returns a list of magic items', async () => {
const itemsData = magicItemFactory.buildList(3)
await MagicItemModel.insertMany(itemsData)

const request = createRequest({ query: {} })
const response = createResponse()

await MagicItemController.index(request, response, mockNext)

expect(response.statusCode).toBe(200)
const responseData = JSON.parse(response._getData())
expect(responseData.count).toBe(3)
expect(responseData.results).toHaveLength(3)
expect(mockNext).not.toHaveBeenCalled()
})

it('filters by name', async () => {
const itemsData = [
magicItemFactory.build({ name: 'Bag of Holding' }),
magicItemFactory.build({ name: 'Cloak of Elvenkind' })
]
await MagicItemModel.insertMany(itemsData)

const request = createRequest({ query: { name: 'Bag' } })
const response = createResponse()

await MagicItemController.index(request, response, mockNext)

expect(response.statusCode).toBe(200)
const responseData = JSON.parse(response._getData())
expect(responseData.count).toBe(1)
expect(responseData.results[0].name).toBe('Bag of Holding')
})
})

describe('show', () => {
it('returns a single magic item when found', async () => {
const itemData = magicItemFactory.build({ index: 'bag-of-holding', name: 'Bag of Holding' })
await MagicItemModel.insertMany([itemData])

const request = createRequest({ params: { index: 'bag-of-holding' } })
const response = createResponse()

await MagicItemController.show(request, response, mockNext)

expect(response.statusCode).toBe(200)
const responseData = JSON.parse(response._getData())
expect(responseData.index).toBe('bag-of-holding')
expect(responseData.name).toBe('Bag of Holding')
expect(mockNext).not.toHaveBeenCalled()
})

it('calls next() when the magic item is not found', async () => {
const request = createRequest({ params: { index: 'nonexistent' } })
const response = createResponse()

await MagicItemController.show(request, response, mockNext)

expect(response._getData()).toBe('')
expect(mockNext).toHaveBeenCalledOnce()
})
})
})
Loading
Loading