import request from 'supertest'; import express, { Application } from 'express'; import configRoutes from '../../../src/routes/config'; import locationRoutes from '../../../src/routes/locations'; import Location from '../../../src/models/Location'; import { createTestDatabase } from '../../setup'; import { Database } from 'sqlite3'; describe('Public API Routes', () => { let app: Application; let db: Database; let locationModel: Location; beforeEach(async () => { // Setup Express app app = express(); app.use(express.json()); // Setup test database and models db = await createTestDatabase(); locationModel = new Location(db); // Mock profanity filter const mockProfanityFilter = { analyzeProfanity: jest.fn().mockReturnValue({ hasProfanity: false, matches: [], severity: 'none', count: 0, filtered: 'test text' }) }; // Setup routes app.use('/api/config', configRoutes()); app.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter)); }); afterEach((done) => { db.close(done); }); describe('GET /api/config', () => { it('should return API configuration', async () => { const response = await request(app) .get('/api/config') .expect(200); expect(response.body).toHaveProperty('mapboxAccessToken'); expect(response.body).toHaveProperty('hasMapbox'); expect(typeof response.body.hasMapbox).toBe('boolean'); }); it('should return mapbox token when configured', async () => { process.env.MAPBOX_ACCESS_TOKEN = 'pk.test_token'; const response = await request(app) .get('/api/config') .expect(200); expect(response.body.mapboxAccessToken).toBe('pk.test_token'); expect(response.body.hasMapbox).toBe(true); }); it('should handle missing mapbox token', async () => { delete process.env.MAPBOX_ACCESS_TOKEN; const response = await request(app) .get('/api/config') .expect(200); expect(response.body.mapboxAccessToken).toBeNull(); expect(response.body.hasMapbox).toBe(false); }); }); describe('GET /api/locations', () => { beforeEach(async () => { // Add test locations await locationModel.create({ address: 'Test Location 1', latitude: 42.9634, longitude: -85.6681, description: 'Test description 1' }); await locationModel.create({ address: 'Test Location 2', latitude: 42.9584, longitude: -85.6706, description: 'Test description 2' }); }); it('should return all active locations', async () => { const response = await request(app) .get('/api/locations') .expect(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body).toHaveLength(2); expect(response.body[0]).toHaveProperty('id'); expect(response.body[0]).toHaveProperty('address'); expect(response.body[0]).toHaveProperty('latitude'); expect(response.body[0]).toHaveProperty('longitude'); }); it('should return empty array when no locations exist', async () => { // Clear all locations await locationModel.delete(1); await locationModel.delete(2); const response = await request(app) .get('/api/locations') .expect(200); expect(response.body).toEqual([]); }); it('should handle database errors gracefully', async () => { // Create a new app with broken database to simulate error const brokenApp = express(); brokenApp.use(express.json()); // Create a broken location model that throws errors const brokenLocationModel = { getActive: jest.fn().mockRejectedValue(new Error('Database error')) }; const mockProfanityFilter = { analyzeProfanity: jest.fn().mockReturnValue({ hasProfanity: false, matches: [], severity: 'none', count: 0, filtered: 'test text' }) }; brokenApp.use('/api/locations', locationRoutes(brokenLocationModel as any, mockProfanityFilter)); const response = await request(brokenApp) .get('/api/locations') .expect(500); expect(response.body).toHaveProperty('error'); expect(response.body.error).toBe('Internal server error'); }); }); describe('POST /api/locations', () => { it('should create a new location with valid data', async () => { const locationData = { address: 'New Test Location', latitude: 42.9634, longitude: -85.6681, description: 'New test description' }; const response = await request(app) .post('/api/locations') .send(locationData) .expect(200); expect(response.body).toHaveProperty('id'); expect(response.body.address).toBe(locationData.address); expect(response.body.latitude).toBe(locationData.latitude); expect(response.body.longitude).toBe(locationData.longitude); expect(response.body.description).toBe(locationData.description); expect(response.body).toHaveProperty('created_at'); }); it('should create location with only required address', async () => { const locationData = { address: 'Minimal Location' }; const response = await request(app) .post('/api/locations') .send(locationData) .expect(200); expect(response.body).toHaveProperty('id'); expect(response.body.address).toBe(locationData.address); }); it('should reject request without address', async () => { const locationData = { latitude: 42.9634, longitude: -85.6681, description: 'Missing address' }; const response = await request(app) .post('/api/locations') .send(locationData) .expect(400); expect(response.body).toHaveProperty('error'); expect(response.body.error).toBe('Address is required'); }); it('should reject request with profanity in description', async () => { // Setup mock to detect profanity const app2 = express(); app2.use(express.json()); const mockProfanityFilter = { analyzeProfanity: jest.fn().mockReturnValue({ hasProfanity: true, matches: [{ word: 'badword', category: 'general' }], severity: 'medium', count: 1, filtered: '*** text' }) }; app2.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter)); const locationData = { address: 'Test Location', description: 'This contains profanity' }; const response = await request(app2) .post('/api/locations') .send(locationData) .expect(400); expect(response.body).toHaveProperty('error'); expect(response.body.error).toBe('Submission rejected'); expect(response.body).toHaveProperty('message'); expect(response.body).toHaveProperty('details'); }); it('should handle empty description', async () => { const locationData = { address: 'Test Location', description: '' }; const response = await request(app) .post('/api/locations') .send(locationData) .expect(200); expect(response.body).toHaveProperty('id'); expect(response.body.address).toBe(locationData.address); }); it('should handle missing optional fields', async () => { const locationData = { address: 'Test Location' // No latitude, longitude, or description }; const response = await request(app) .post('/api/locations') .send(locationData) .expect(200); expect(response.body).toHaveProperty('id'); expect(response.body.address).toBe(locationData.address); }); it('should handle profanity filter errors gracefully', async () => { const app2 = express(); app2.use(express.json()); const mockProfanityFilter = { analyzeProfanity: jest.fn().mockImplementation(() => { throw new Error('Filter error'); }) }; app2.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter)); const locationData = { address: 'Test Location', description: 'Test description' }; // Should still succeed if profanity filter fails const response = await request(app2) .post('/api/locations') .send(locationData) .expect(200); expect(response.body).toHaveProperty('id'); }); }); describe('Content-Type validation', () => { it('should accept JSON content type', async () => { const response = await request(app) .post('/api/locations') .set('Content-Type', 'application/json') .send({ address: 'Test Location' }) .expect(200); expect(response.body).toHaveProperty('id'); }); it('should handle malformed JSON', async () => { const response = await request(app) .post('/api/locations') .set('Content-Type', 'application/json') .send('{"address": "Test Location"') // Missing closing brace .expect(400); // Should return bad request for malformed JSON }); }); describe('Request validation', () => { it('should reject overly long addresses for security', async () => { const longAddress = 'A'.repeat(1000); // Over 500 character limit const response = await request(app) .post('/api/locations') .send({ address: longAddress }) .expect(400); expect(response.body).toHaveProperty('error'); expect(response.body.error).toBe('Address must be a string with maximum 500 characters'); }); it('should accept addresses within the limit', async () => { const validLongAddress = 'A'.repeat(400); // Under 500 character limit const response = await request(app) .post('/api/locations') .send({ address: validLongAddress }) .expect(200); expect(response.body.address).toBe(validLongAddress); }); it('should reject overly long descriptions for security', async () => { const longDescription = 'B'.repeat(1500); // Over 1000 character limit const response = await request(app) .post('/api/locations') .send({ address: 'Test Address', description: longDescription }) .expect(400); expect(response.body).toHaveProperty('error'); expect(response.body.error).toBe('Description must be a string with maximum 1000 characters'); }); it('should handle special characters in address', async () => { const specialAddress = 'Main St & Oak Ave, Grand Rapids, MI 49503'; const response = await request(app) .post('/api/locations') .send({ address: specialAddress }) .expect(200); expect(response.body.address).toBe(specialAddress); }); it('should handle unicode characters', async () => { const unicodeAddress = 'Straße München, Deutschland 🇩🇪'; const response = await request(app) .post('/api/locations') .send({ address: unicodeAddress }) .expect(200); expect(response.body.address).toBe(unicodeAddress); }); }); });