ice/tests/integration/routes/public.test.ts
Claude Code f8802232c6 Fix TypeScript linting issues and test failures
- Replace 37 instances of 'any' type with proper TypeScript types
- Fix trailing spaces in i18n.test.ts
- Add proper interfaces for profanity analysis and matches
- Extend Express Request interface with custom properties
- Fix error handling in ProfanityFilterService for constraint violations
- Update test mocks to satisfy TypeScript strict checking
- All 147 tests now pass with 0 linting errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 21:14:54 -04:00

503 lines
No EOL
16 KiB
TypeScript

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'
}),
containsProfanity: jest.fn().mockReturnValue(false),
filterProfanity: jest.fn().mockReturnValue('test text'),
addCustomWord: jest.fn(),
removeCustomWord: jest.fn(),
updateCustomWord: jest.fn(),
getCustomWords: jest.fn().mockResolvedValue([]),
loadCustomWords: jest.fn().mockResolvedValue(undefined),
getAllWords: jest.fn().mockReturnValue([]),
getSeverity: jest.fn().mockReturnValue('none'),
getSeverityLevel: jest.fn().mockReturnValue(0),
getSeverityName: jest.fn().mockReturnValue('none'),
normalizeText: jest.fn().mockReturnValue('test text'),
buildPatterns: jest.fn().mockReturnValue([]),
close: jest.fn(),
_isFallback: false,
profanityWordModel: {} as any,
isInitialized: true
} as any;
// 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'
}),
containsProfanity: jest.fn().mockReturnValue(false),
filterProfanity: jest.fn().mockReturnValue('test text'),
addCustomWord: jest.fn(),
removeCustomWord: jest.fn(),
updateCustomWord: jest.fn(),
getCustomWords: jest.fn().mockResolvedValue([]),
loadCustomWords: jest.fn().mockResolvedValue(undefined),
getAllWords: jest.fn().mockReturnValue([]),
getSeverity: jest.fn().mockReturnValue('none'),
getSeverityLevel: jest.fn().mockReturnValue(0),
getSeverityName: jest.fn().mockReturnValue('none'),
normalizeText: jest.fn().mockReturnValue('test text'),
buildPatterns: jest.fn().mockReturnValue([]),
close: jest.fn(),
_isFallback: false,
profanityWordModel: {} as any,
isInitialized: true
} as any;
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'
}),
containsProfanity: jest.fn().mockReturnValue(false),
filterProfanity: jest.fn().mockReturnValue('test text'),
addCustomWord: jest.fn(),
removeCustomWord: jest.fn(),
updateCustomWord: jest.fn(),
getCustomWords: jest.fn().mockResolvedValue([]),
loadCustomWords: jest.fn().mockResolvedValue(undefined),
getAllWords: jest.fn().mockReturnValue([]),
getSeverity: jest.fn().mockReturnValue('none'),
getSeverityLevel: jest.fn().mockReturnValue(0),
getSeverityName: jest.fn().mockReturnValue('none'),
normalizeText: jest.fn().mockReturnValue('test text'),
buildPatterns: jest.fn().mockReturnValue([]),
close: jest.fn(),
_isFallback: false,
profanityWordModel: {} as any,
isInitialized: true
} as any;
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');
}),
containsProfanity: jest.fn().mockReturnValue(false),
filterProfanity: jest.fn().mockReturnValue('test text'),
addCustomWord: jest.fn(),
removeCustomWord: jest.fn(),
updateCustomWord: jest.fn(),
getCustomWords: jest.fn().mockResolvedValue([]),
loadCustomWords: jest.fn().mockResolvedValue(undefined),
getAllWords: jest.fn().mockReturnValue([]),
getSeverity: jest.fn().mockReturnValue('none'),
getSeverityLevel: jest.fn().mockReturnValue(0),
getSeverityName: jest.fn().mockReturnValue('none'),
normalizeText: jest.fn().mockReturnValue('test text'),
buildPatterns: jest.fn().mockReturnValue([]),
close: jest.fn(),
_isFallback: false,
profanityWordModel: {} as any,
isInitialized: true
} as any;
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 () => {
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);
});
it('should reject invalid latitude values', async () => {
const invalidLatitudes = [91, -91, 'invalid', null, true, []];
for (const latitude of invalidLatitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: latitude,
longitude: -85.6681
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Latitude must be a number between -90 and 90');
}
});
it('should reject invalid longitude values', async () => {
const invalidLongitudes = [181, -181, 'invalid', null, true, []];
for (const longitude of invalidLongitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: 42.9634,
longitude: longitude
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Longitude must be a number between -180 and 180');
}
});
it('should accept valid latitude and longitude values', async () => {
const validCoordinates = [
{ latitude: 0, longitude: 0 },
{ latitude: 90, longitude: 180 },
{ latitude: -90, longitude: -180 },
{ latitude: 42.9634, longitude: -85.6681 }
];
for (const coords of validCoordinates) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: coords.latitude,
longitude: coords.longitude
})
.expect(200);
expect(response.body.latitude).toBe(coords.latitude);
expect(response.body.longitude).toBe(coords.longitude);
}
});
});
});