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
12 changes: 9 additions & 3 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import AppDataSource from './data-source';
import { UsersModule } from './users/users.module';
import { TagsModule } from './tags/tags.module';
import { ResourcesModule } from './resources/resources.module';
import { AuthModule } from './auth/auth.module';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../../.env',
}),
TypeOrmModule.forRoot({
...AppDataSource.options,
migrations: [], // Don't load migrations when server starts - only load them when running migration commands (i had to add this to get the server to run without errors)
}),
TypeOrmModule.forRoot(AppDataSource.options),
UsersModule,
AuthModule,
ResourcesModule,
TagsModule,
AuthModule,
],
controllers: [AppController],
providers: [AppService],
providers: [AppService]
})
export class AppModule {}
11 changes: 7 additions & 4 deletions apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { DataSource } from 'typeorm';
import { PluralNamingStrategy } from './strategies/plural-naming.strategy';
import * as dotenv from 'dotenv';
import { Tag } from './tags/tag.entity';
import { Resource } from './resources/resources.entity';
import { Score } from './score/score.entity';
import { User } from './users/user.entity';

dotenv.config();

Expand All @@ -21,10 +25,9 @@ const AppDataSource = new DataSource({
username: process.env.NX_DB_USERNAME ?? process.env.POSTGRES_USER,
password: process.env.NX_DB_PASSWORD ?? process.env.POSTGRES_PASSWORD,
database: process.env.NX_DB_DATABASE ?? process.env.POSTGRES_DB,
entities: [
'apps/backend/src/tags/tags.entity.ts',
'apps/backend/src/resources/resources.entity.ts',
],
// Entities are imported directly rather than globbed by path, since glob
// strings don't resolve once the app is bundled/copied for production.
entities: [Tag, Resource, Score, User],
migrations: [
'apps/backend/src/migrations/*.js',
'apps/backend/src/migrations/*.ts',
Expand Down
45 changes: 44 additions & 1 deletion apps/backend/src/tags/tags.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Controller } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { TagsService } from './tags.service';
import { Tag } from './tag.entity';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Category } from './types';

/**
* Controller for managing tags. Provides endpoints for retrieving and managing tags in the system.
Expand All @@ -11,4 +12,46 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@Controller('tags')
export class TagsController {
constructor(private tagsService: TagsService) {}

/**
* Retrieves tags by a list of ids
* @param ids an array of tag IDs to retreive
*
* @returns an array of tags matching the provided IDs
*/
@Get('/findById')
async getTagsByIds(
@Query('ids') ids?: string,
): Promise<Tag[]> {
const tagIds = ids ? ids.split(',').map(Number) : [];

return this.tagsService.getTagsByIds(tagIds);
}

/**
* Retrieves a single tag by its ID.
Comment thread
SamNie2027 marked this conversation as resolved.
* @param tagId the unique ID of a tag to get
*
* @returns The tag matching the provided ID
*/
@Get('/:tagId')
async getTagById(
@Param('tagId', ParseIntPipe) tagId: number,
): Promise<Tag> {
return this.tagsService.getTagById(tagId);
}


/**
* Retrieves tags by their category
* @param category the category used to get tags
*
* @returns an array of tags matching the provided category
*/
@Get('/category/:category')
async getTagsByCategory(
@Param('category') category: Category,
) : Promise<Tag[]> {
return this.tagsService.getTagsByCategory(category);
}
}
46 changes: 0 additions & 46 deletions apps/backend/src/tags/tags.entity.ts

This file was deleted.

115 changes: 114 additions & 1 deletion apps/backend/src/tags/tags.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Tag } from './tag.entity';
import { TagsService } from './tags.service';
import { Category } from './types';
Expand All @@ -11,13 +11,17 @@ describe('TagsService', () => {
count: jest.Mock;
create: jest.Mock;
save: jest.Mock;
findOneBy: jest.Mock;
findBy: jest.Mock;
};

beforeEach(async () => {
repository = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn(),
save: jest.fn(),
findOneBy: jest.fn(),
findBy: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -124,4 +128,113 @@ describe('TagsService', () => {
).rejects.toThrow(new BadRequestException('Slug cannot contain spaces.'));
expect(repository.save).not.toHaveBeenCalled();
});

it('returns a tag when the tag id exists', async () => {
const tag = {
tag_id: 1,
category: Category.FOOD_SERVICE_TYPE,
label: 'Farmers Market',
slug: 'farmers-market',
};

repository.findOneBy.mockResolvedValue(tag);

await expect(
service.getTagById(1),
).resolves.toEqual(tag);

expect(repository.findOneBy).toHaveBeenCalledWith({ tag_id: 1, });
});

it('rejects when a tag id cannot be found', async () => {
repository.findOneBy.mockResolvedValue(null);

await expect(
service.getTagById(999),
).rejects.toThrow('The tag id: 999 could not be found.');
});

it('returns tags when the category exists', async () => {
const tags = [
{
tag_id: 1,
category: Category.FOOD_SERVICE_TYPE,
label: 'Farmers Market',
slug: 'farmers-market',
},
{
tag_id: 2,
category: Category.FOOD_SERVICE_TYPE,
label: 'Food Pantry',
slug: 'food-pantry',
},
];

repository.findBy.mockResolvedValue(tags);
await expect(service.getTagsByCategory(Category.FOOD_SERVICE_TYPE),
).resolves.toEqual(tags);
expect(repository.findBy).toHaveBeenCalledWith({ category: Category.FOOD_SERVICE_TYPE, })
})

it('returns an empty array when no tags match the category', async() => {
repository.findBy.mockResolvedValue([]);

await expect(service.getTagsByCategory(Category.NUTRITION_PROGRAM),
).resolves.toEqual([]);
expect(repository.findBy).toHaveBeenCalledWith({ category: Category.NUTRITION_PROGRAM, })
})

it('rejects an invalid/nonexistent category', async () => {
await expect(
service.getTagsByCategory('fake_category' as Category),
).rejects.toThrow(`fake_category is not a valid Category. Valid categories are: ${Object.values(Category).join(', ')}.`);
});

it('gets tags by ids', async () => {
const tags = [
{
tag_id: 1,
category: Category.FOOD_TYPE,
label: 'Food Pantry',
slug: 'food-pantry',
},
{
tag_id: 2,
category: Category.FOOD_TYPE,
label: 'Grocery Store',
slug: 'grocery-store',
},
];

repository.findBy.mockResolvedValue(tags);

await expect(
service.getTagsByIds([1, 2]),
).resolves.toEqual(tags);
});

it('rejects an empty list of tag ids', async () => {
await expect(
service.getTagsByIds([]),
).rejects.toThrow('At least one tag ID is required.');
});

it('returns only the tags that exist when some ids are missing', async () => {
const tag = { tag_id: 1, category: 'food_service_type', label: 'Test', slug: 'test' };
repository.findBy.mockResolvedValue([tag]);

await expect(service.getTagsByIds([1, 999])).resolves.toEqual([tag]);
});

it('throws NotFoundException when none of the ids exist', async () => {
repository.findBy.mockResolvedValue([]);

await expect(service.getTagsByIds([998, 999])).rejects.toThrow(`No tags found for ids: 998, 999.`);
})

it('rejects non-integer or non-positive tag ids', async () => {
await expect(service.getTagsByIds([1, NaN])).rejects.toThrow('Tag IDs must be positive integers.');
await expect(service.getTagsByIds([0])).rejects.toThrow('Tag IDs must be positive integers.');
await expect(service.getTagsByIds([1.5])).rejects.toThrow('Tag IDs must be positive integers.');
});
});
59 changes: 57 additions & 2 deletions apps/backend/src/tags/tags.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';

import { Tag } from './tag.entity';
import { Category } from './types';
Expand Down Expand Up @@ -64,4 +64,59 @@ export class TagsService {

return this.repo.save(tag);
}

/**
* Gets a tag by its id.
* @param tagId The unique id associated with a tag, which is the PK of tag table.
*
* @returns A tag with all its associated info.
*/
async getTagById(tagId: number) {
const tag = await this.repo.findOneBy( { tag_id: tagId } );
if (!tag) {
throw new NotFoundException(`The tag id: ${tagId} could not be found.`);
}
return tag;
}

/**
* Gets multiple tags by their ids. IDs that don't exist are skipped.
* @param tagIds An array of tag ids to query by.
*
* @returns An array of the tags that exist among the provided ids.
* @throws BadRequestException if no ids are provided or any id is not a positive integer.
* @throws NotFoundException if none of the provided ids exist.
*/
async getTagsByIds(tagIds: number[]): Promise<Tag[]> {
if (!tagIds || tagIds.length === 0) {
throw new BadRequestException('At least one tag ID is required.');
}

if (tagIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('Tag IDs must be positive integers.');
}

const tags = await this.repo.findBy({ tag_id: In(tagIds) });

if (tags.length === 0) {
throw new NotFoundException(`No tags found for ids: ${tagIds.join(', ')}.`);
}

return tags;
}


/**
* Gets (multiple) tags by their category.
* @param category A valid category enum used to filter the tags.
*
* @returns An array of tags.
*/
async getTagsByCategory(category: Category) {
if (!Object.values(Category).includes(category)) {
throw new BadRequestException(`${category} is not a valid Category. Valid categories are: ${Object.values(Category).join(', ')}.`);
}

return this.repo.findBy( { category });
}
}
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8160,7 +8160,7 @@ merge-stream@^2.0.0:

merge2@^1.3.0, merge2@^1.4.1:
version "1.4.1"
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==

micromatch@^4.0.8:
Expand Down
Loading