diff --git a/backend/src/__tests__/species.ts b/backend/src/__tests__/species.ts index 8eab6af1..a4e40baf 100644 --- a/backend/src/__tests__/species.ts +++ b/backend/src/__tests__/species.ts @@ -22,6 +22,7 @@ species(router); app.use(router); const speciesMock = Species.findById as jest.Mock; const speciesFindMock = Species.findOne as jest.Mock; +const speciesFindQueryMock = Species.find as jest.Mock; describe('GET /species/id', () => { beforeEach(() => { @@ -66,3 +67,70 @@ describe('GET /species/scientific/id', () => { expect(res.body).toEqual(species); }); }); + +describe('GET /species/query', () => { + beforeEach(() => { + speciesFindQueryMock.mockReset(); + }); + + it('Returns species results for a valid query string', async () => { + const queryString = 'salmon'; + const mockResults = [ + { + _id: new mongoose.Types.ObjectId().toString(), + scientificName: 'Salmo salar', + commonNames: ['Atlantic salmon'], + }, + { + _id: new mongoose.Types.ObjectId().toString(), + scientificName: 'Oncorhynchus tshawytscha', + commonNames: ['Chinook salmon'], + }, + ]; + + speciesFindQueryMock.mockReturnValue({ + sort: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue(mockResults), + }), + }); + + const res = await request(app).get(`/species/query?q=${queryString}`); + expect(res.status).toBe(200); + expect(res.body).toEqual(mockResults); + }); + + it('Returns top alphabetically sorted species when no query is provided', async () => { + const mockResults = [ + { + _id: new mongoose.Types.ObjectId().toString(), + scientificName: 'Abramis brama', + commonNames: ['Bream'], + }, + { + _id: new mongoose.Types.ObjectId().toString(), + scientificName: 'Alosa alosa', + commonNames: ['Allis shad'], + }, + ]; + + speciesFindQueryMock.mockReturnValue({ + sort: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue(mockResults), + }), + }); + + const res = await request(app).get('/species/query'); + expect(res.status).toBe(200); + expect(res.body).toEqual(mockResults); + }); + + it('Handles internal server errors gracefully', async () => { + speciesFindQueryMock.mockImplementation(() => { + throw new Error('Database error'); + }); + + const res = await request(app).get('/species/query?q=salmon'); + expect(res.status).toBe(500); + expect(res.body).toEqual({ message: 'Internal server error' }); + }); +}); diff --git a/backend/src/controllers/species/get.ts b/backend/src/controllers/species/get.ts index 36428e91..dbc54a49 100644 --- a/backend/src/controllers/species/get.ts +++ b/backend/src/controllers/species/get.ts @@ -17,3 +17,26 @@ export const getByScientificName = async ( if (!species) return res.status(404).json({ message: 'Species not found' }); return res.status(200).json(species); }; + +export const getSpeciesBySearch = async ( + req: express.Request, + res: express.Response, +) => { + const query = req.query.q ? req.query.q.toString().trim() : ''; + + try { + let species; + if (query) { + species = await Species.find({ $text: { $search: query } }) + .sort({ score: { $meta: 'textScore' } }) // Sort by relevance + .limit(5); // Limit to 5 results + } else { + // Default: Get the top 5 species sorted alphabetically by scientificName + species = await Species.find({}).sort({ scientificName: 1 }).limit(5); + } + return res.status(200).json(species); + } catch (error) { + console.error('Error querying species:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; diff --git a/backend/src/middlewares/authMiddleware.ts b/backend/src/middlewares/authMiddleware.ts index 3496ef27..f6b9e44f 100644 --- a/backend/src/middlewares/authMiddleware.ts +++ b/backend/src/middlewares/authMiddleware.ts @@ -10,4 +10,5 @@ export const isAuthenticated = ( } else { res.status(401).json({ error: 'Unauthorized' }); } + next(); }; diff --git a/backend/src/models/species.ts b/backend/src/models/species.ts index 1d6ca1da..e039b56a 100644 --- a/backend/src/models/species.ts +++ b/backend/src/models/species.ts @@ -10,4 +10,6 @@ const SpeciesSchema = new mongoose.Schema({ imageUrls: [String], }); +SpeciesSchema.index({ commonNames: 'text', scientificName: 'text' }); + export const Species = mongoose.model('Species', SpeciesSchema); diff --git a/backend/src/routes/species.ts b/backend/src/routes/species.ts index a92bc066..9b12a983 100644 --- a/backend/src/routes/species.ts +++ b/backend/src/routes/species.ts @@ -1,6 +1,10 @@ import express from 'express'; import { isAuthenticated } from '../middlewares/authMiddleware'; -import { getById, getByScientificName } from '../controllers/species/get'; +import { + getById, + getByScientificName, + getSpeciesBySearch, +} from '../controllers/species/get'; /** * @swagger @@ -40,6 +44,24 @@ import { getById, getByScientificName } from '../controllers/species/get'; * description: Unauthorized * 404: * description: species not found + * /species/query: + * get: + * summary: Query species + * description: Search for species by a query string or retrieve top results alphabetically. + * parameters: + * - in: query + * name: q + * required: false + * description: Query string to search for species + * schema: + * type: string + * responses: + * 200: + * description: Successfully retrieved species results + * 401: + * description: Unauthorized + * 500: + * description: Internal server error */ export default (router: express.Router) => { router.get('/species/id/:id', isAuthenticated, getById); @@ -48,4 +70,5 @@ export default (router: express.Router) => { isAuthenticated, getByScientificName, ); + router.get('/species/query', isAuthenticated, getSpeciesBySearch); };