- 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>
503 lines
No EOL
16 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|
|
}); |