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
96 changes: 96 additions & 0 deletions backend/src/modules/org-inventory/org-inventory.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { BadRequestException } from '@nestjs/common';
import { OrgInventoryController } from './org-inventory.controller';
import { OrgInventoryService } from './org-inventory.service';

describe('OrgInventoryController', () => {
let controller: OrgInventoryController;
let orgInventoryService: jest.Mocked<Pick<OrgInventoryService, 'search'>>;

beforeEach(() => {
orgInventoryService = {
search: jest.fn().mockResolvedValue({
items: [],
total: 0,
limit: 25,
offset: 0,
}),
};

controller = new OrgInventoryController(
orgInventoryService as unknown as OrgInventoryService,
);
});

it('accepts camelCase query aliases for org inventory filters', async () => {
await controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
categoryId: '5',
uexItemId: '9',
locationId: '12',
minQuantity: '0.25',
maxQuantity: '10.5',
limit: '25',
offset: '50',
});

expect(orgInventoryService.search).toHaveBeenCalledWith(7, {
orgId: 42,
gameId: 1,
categoryId: 5,
uexItemId: 9,
locationId: 12,
search: undefined,
limit: 25,
offset: 50,
sort: undefined,
order: undefined,
activeOnly: undefined,
minQuantity: 0.25,
maxQuantity: 10.5,
});
});

it('throws a bad request for invalid numeric pagination params', async () => {
await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
limit: 'abc',
}),
).rejects.toThrow(new BadRequestException('limit must be a number'));
});

it('throws a bad request for non-integer or out-of-range pagination params', async () => {
await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
limit: '10.5',
}),
).rejects.toThrow(new BadRequestException('limit must be an integer'));

await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
offset: '-1',
}),
).rejects.toThrow(
new BadRequestException('offset must be greater than or equal to 0'),
);
});

it('throws a bad request for non-integer id-like filters', async () => {
await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1.5',
}),
).rejects.toThrow(new BadRequestException('game_id must be an integer'));

await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
locationId: '2.5',
}),
).rejects.toThrow(
new BadRequestException('location_id must be an integer'),
);
});
});
116 changes: 111 additions & 5 deletions backend/src/modules/org-inventory/org-inventory.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Post,
Put,
Delete,
BadRequestException,
Body,
Param,
Query,
Expand Down Expand Up @@ -38,6 +39,41 @@ import {
export class OrgInventoryController {
constructor(private readonly orgInventoryService: OrgInventoryService) {}

private readOptionalNumber(
query: Record<string, any>,
keys: string[],
fieldName: string,
options?: {
integer?: boolean;
min?: number;
},
): number | undefined {
const rawValue = keys
.map((key) => query[key])
.find((value) => value !== undefined);

if (rawValue === undefined || rawValue === '') {
return undefined;
}

const parsedValue = Number(rawValue);
if (Number.isNaN(parsedValue)) {
throw new BadRequestException(`${fieldName} must be a number`);
}

if (options?.integer && !Number.isInteger(parsedValue)) {
throw new BadRequestException(`${fieldName} must be an integer`);
}

if (options?.min !== undefined && parsedValue < options.min) {
throw new BadRequestException(
`${fieldName} must be greater than or equal to ${options.min}`,
);
}

return parsedValue;
}

/**
* List org inventory items with filtering
* GET /api/orgs/:orgId/inventory
Expand All @@ -53,19 +89,88 @@ export class OrgInventoryController {
async list(
@Request() req: any,
@Param('orgId', ParseIntPipe) orgId: number,
@Query() searchDto: OrgInventorySearchDto,
@Query() query: Record<string, any>,
): Promise<{
items: OrgInventoryItemDto[];
total: number;
limit: number;
offset: number;
}> {
const userId = req.user.userId;
// Extract gameId from query params and use search
return this.orgInventoryService.search(userId, {
...searchDto,
const gameId = this.readOptionalNumber(
query,
['game_id', 'gameId'],
'game_id',
{
integer: true,
min: 1,
},
);

const searchDto: OrgInventorySearchDto = {
orgId,
});
gameId: gameId ?? 0,
categoryId: this.readOptionalNumber(
query,
['category_id', 'categoryId'],
'category_id',
{
integer: true,
min: 1,
},
),
uexItemId: this.readOptionalNumber(
query,
['uex_item_id', 'uexItemId'],
'uex_item_id',
{
integer: true,
min: 1,
},
),
locationId: this.readOptionalNumber(
query,
['location_id', 'locationId'],
'location_id',
{
integer: true,
min: 1,
},
),
search: query.search,
limit: this.readOptionalNumber(query, ['limit'], 'limit', {
integer: true,
min: 1,
}),
offset: this.readOptionalNumber(query, ['offset'], 'offset', {
integer: true,
min: 0,
}),
sort: query.sort,
order: query.order,
activeOnly:
query.active_only !== undefined
? query.active_only === 'true' || query.active_only === true
: query.activeOnly !== undefined
? query.activeOnly === 'true' || query.activeOnly === true
: undefined,
minQuantity: this.readOptionalNumber(
query,
['min_quantity', 'minQuantity'],
'min_quantity',
),
maxQuantity: this.readOptionalNumber(
query,
['max_quantity', 'maxQuantity'],
'max_quantity',
),
};

if (!searchDto.gameId) {
throw new BadRequestException('game_id is required');
}

return this.orgInventoryService.search(userId, searchDto);
}

/**
Expand All @@ -83,6 +188,7 @@ export class OrgInventoryController {
})
@ApiResponse({ status: 400, description: 'Invalid input' })
@ApiResponse({ status: 403, description: 'Permission denied' })
@ApiResponse({ status: 409, description: 'Inventory item already exists' })
async create(
@Request() req: any,
@Param('orgId', ParseIntPipe) orgId: number,
Expand Down
24 changes: 22 additions & 2 deletions backend/src/modules/org-inventory/org-inventory.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,26 @@ export class OrgInventoryRepository extends Repository<OrgInventoryItem> {
});
}

/**
* Find an existing org inventory item with matching composite keys
*/
async findExistingItem(params: {
orgId: number;
gameId: number;
uexItemId: number;
locationId: number;
}): Promise<OrgInventoryItem | null> {
return this.findOne({
where: {
orgId: params.orgId,
gameId: params.gameId,
uexItemId: params.uexItemId,
locationId: params.locationId,
deleted: false,
},
});
}

/**
* Soft delete an inventory item
*/
Expand Down Expand Up @@ -227,10 +247,10 @@ export class OrgInventoryRepository extends Repository<OrgInventoryItem> {
case 'location':
return 'location.displayName';
case 'date_added':
return 'oii.date_added';
return 'oii.dateAdded';
case 'date_modified':
default:
return 'oii.date_modified';
return 'oii.dateModified';
}
}
}
26 changes: 25 additions & 1 deletion backend/src/modules/org-inventory/org-inventory.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { OrgInventoryService } from './org-inventory.service';
import { OrgInventoryRepository } from './org-inventory.repository';
import { PermissionsService } from '../permissions/permissions.service';
Expand Down Expand Up @@ -68,6 +72,7 @@ describe('OrgInventoryService', () => {
useValue: {
findByOrgIdAndGameId: jest.fn(),
findByIdNotDeleted: jest.fn(),
findExistingItem: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDeleteItem: jest.fn(),
Expand Down Expand Up @@ -107,6 +112,7 @@ describe('OrgInventoryService', () => {

it('should create org inventory item with manage permission', async () => {
jest.spyOn(permissionsService, 'hasPermission').mockResolvedValue(true);
jest.spyOn(repository, 'findExistingItem').mockResolvedValue(null);
jest.spyOn(repository, 'create').mockReturnValue(mockOrgInventoryItem);
jest.spyOn(repository, 'save').mockResolvedValue(mockOrgInventoryItem);
jest
Expand All @@ -121,6 +127,12 @@ describe('OrgInventoryService', () => {
1,
OrgPermission.CAN_EDIT_ORG_INVENTORY,
);
expect(repository.findExistingItem).toHaveBeenCalledWith({
orgId: createDto.orgId,
gameId: createDto.gameId,
uexItemId: createDto.uexItemId,
locationId: createDto.locationId,
});
expect(repository.create).toHaveBeenCalledWith({
...createDto,
addedBy: 1,
Expand All @@ -138,6 +150,18 @@ describe('OrgInventoryService', () => {
);
expect(repository.create).not.toHaveBeenCalled();
});

it('should throw ConflictException when item already exists', async () => {
jest.spyOn(permissionsService, 'hasPermission').mockResolvedValue(true);
jest
.spyOn(repository, 'findExistingItem')
.mockResolvedValue(mockOrgInventoryItem);

await expect(service.create(1, createDto)).rejects.toThrow(
ConflictException,
);
expect(repository.create).not.toHaveBeenCalled();
});
});

describe('findByOrgAndGame', () => {
Expand Down
14 changes: 14 additions & 0 deletions backend/src/modules/org-inventory/org-inventory.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { OrgInventoryRepository } from './org-inventory.repository';
import { PermissionsService } from '../permissions/permissions.service';
Expand Down Expand Up @@ -88,6 +89,19 @@ export class OrgInventoryService {

await this.verifyInventoryPermission(userId, dto.orgId, 'manage');

const existing = await this.orgInventoryRepository.findExistingItem({
orgId: dto.orgId,
gameId: dto.gameId,
uexItemId: dto.uexItemId,
locationId: dto.locationId,
});

if (existing) {
throw new ConflictException(
'Inventory item already exists for this organization and location',
);
}

const item = this.orgInventoryRepository.create({
...dto,
orgId: dto.orgId, // Ensure orgId is set
Expand Down
Loading
Loading