diff --git a/javascriptapp/cities.json b/javascriptapp/cities.json new file mode 100644 index 0000000..2596c79 --- /dev/null +++ b/javascriptapp/cities.json @@ -0,0 +1,19 @@ +[ + "London", + "Pinehurst", + "New York", + "Tokyo", + "Paris", + "Berlin", + "Sydney", + "Moscow", + "Cairo", + "Rio de Janeiro", + "Beijing", + "Los Angeles", + "Chicago", + "Toronto", + "Mexico City", + "Mumbai" + ] + \ No newline at end of file diff --git a/javascriptapp/public/index.html b/javascriptapp/public/index.html index 69632ad..4160d61 100644 --- a/javascriptapp/public/index.html +++ b/javascriptapp/public/index.html @@ -4,6 +4,7 @@ My Weather App @@ -79,8 +90,12 @@

My Weather App

- - + + +
@@ -95,9 +110,8 @@

diff --git a/javascriptapp/server.js b/javascriptapp/server.js index 4c5f967..66998f7 100644 --- a/javascriptapp/server.js +++ b/javascriptapp/server.js @@ -1,53 +1,90 @@ +// /home/rob/repos/example-projects/javascriptapp/server.js require('dotenv').config(); const express = require('express'); -// const fetch = require('node-fetch'); // If on Node <=17; on Node 18+ you can use the built-in fetch const path = require('path'); +const fs = require('fs'); const app = express(); -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 3000; // Ensure PORT is defined before use const API_KEY = process.env.OPENWEATHER_API_KEY; -// Serve static files from the "public" folder +// --- Load Available Cities --- +let availableCities = []; +try { + const citiesJsonPath = path.join(__dirname, 'cities.json'); + const citiesJsonData = fs.readFileSync(citiesJsonPath, 'utf8'); + availableCities = JSON.parse(citiesJsonData); + console.log(`Successfully loaded ${availableCities.length} cities from cities.json`); +} catch (err) { // Branch 1 (covered by 'Server Startup Error Handling' tests) + console.error("Error reading or parsing cities.json:", err); + availableCities = ['London', 'New York']; + console.warn("Using fallback city list."); +} // End Branch 1 + +// --- Middleware and Routes --- app.use(express.static(path.join(__dirname, 'public'))); -// GET /api/weather?city=London +app.get('/api/cities', (req, res) => { + res.json(availableCities); +}); + app.get('/api/weather', async (req, res) => { - try { + try { // Branch 2 (covered by successful weather tests) const city = req.query.city; - if (!city) { + if (!city) { // Branch 3 (covered by missing city param test) return res.status(400).json({ error: 'Missing city parameter' }); - } + } // End Branch 3 const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=imperial`; + console.log(`Fetching weather for ${city} from: ${url}`); const response = await fetch(url); - if (!response.ok) { - return res.status(response.status).json({ error: 'Failed to fetch weather data' }); - } + + if (!response.ok) { // Branch 4 (covered by fetch mocking tests for non-OK) + let errorData = { message: 'Failed to fetch weather data' }; + try { // Branch 5 (covered by 404 fetch mock test) + const owmError = await response.json(); + errorData.message = owmError.message || errorData.message; // Branch 6 (short-circuit logic) + } catch (parseError) { // Branch 7 (covered by 503 JSON parse fail test) + errorData.message = response.statusText || errorData.message; // Branch 8 (short-circuit logic) + } // End Branch 7 & 5 + console.error(`Error fetching from OpenWeatherMap: ${response.status} - ${errorData.message}`); + return res.status(response.status).json({ error: errorData.message }); + } // End Branch 4 const data = await response.json(); - // Return relevant fields (including icon, feels_like, wind speed) + // Optional chaining provides implicit branches, usually covered if the properties exist/don't exist res.json({ name: data.name, - description: data.weather?.[0]?.description || '', + description: data.weather?.[0]?.description || '', // Branch 9 & 10 (short-circuit) temp: data.main?.temp, feels_like: data.main?.feels_like, wind_speed: data.wind?.speed, icon: data.weather?.[0]?.icon }); - } catch (err) { - console.error(err); + } catch (err) { // Branch 11 (covered by fetch network error test) + console.error('Server error in /api/weather:', err); res.status(500).json({ error: 'Server error' }); - } + } // End Branch 11 & 2 }); -// If this file is run directly, start the server. -/* istanbul ignore next */ -if (require.main === module) { - const PORT = process.env.PORT || 3000; - app.listen(PORT, () => { - console.log(`Server running on http://localhost:${PORT}`); - }); +// --- START: Refactored Server Start Logic --- +// Function to start the server listening +function startServer(port) { + // Make sure to use the 'app' instance defined above + const server = app.listen(port, () => { // Callback is Branch 12 (covered by 'Server Starting Function' test) + console.log(`Server running on http://localhost:${port}`); // Line inside Branch 12 + }); // End Branch 12 + return server; // Return the server instance (useful for closing in tests if needed) } -// Export app for testing -module.exports = app; \ No newline at end of file +// If this file is run directly, call the start function. +// Keep the ignore comment for the condition itself if desired. +/* istanbul ignore if */ // Ignores the 'if' condition itself (Branch 13) +if (require.main === module) { // Branch 13 (ignored) + startServer(PORT); // Call the extracted function +} // End Branch 13 +// --- END: Refactored Server Start Logic --- + + +// Export the app for testing AND the startServer function +module.exports = { app, startServer, PORT }; // Export PORT as well for convenience in tests diff --git a/javascriptapp/tests/server.test.js b/javascriptapp/tests/server.test.js index f5b29f7..778a3e2 100644 --- a/javascriptapp/tests/server.test.js +++ b/javascriptapp/tests/server.test.js @@ -1,55 +1,281 @@ +// /home/rob/repos/example-projects/javascriptapp/tests/server.test.js const request = require('supertest'); -const app = require('../server'); +const fs = require('fs'); +const path = require('path'); -describe('Weather API', () => { - it('returns weather data for a valid city', async () => { - const city = 'London'; - const response = await request(app).get(`/api/weather?city=${city}`); +// --- START: Define expected cities based on cities.json or fallback --- +let expectedCitiesList = []; +const fallbackCitiesList = ['London', 'New York']; +try { + const citiesJsonPath = path.join(__dirname, '../cities.json'); + const citiesJsonData = fs.readFileSync(citiesJsonPath, 'utf8'); + expectedCitiesList = JSON.parse(citiesJsonData); +} catch (err) { + console.warn('Test setup: Could not read cities.json, using fallback list for tests.'); + expectedCitiesList = fallbackCitiesList; +} +// --- END: Define expected cities --- +// --- Test Suite for Normal Operation --- +describe('Server Normal Operation', () => { + // Import app for request testing, PORT for startServer testing + // Use the DESTRUCTURED import based on the refactored server.js export + const { app, PORT } = require('../server'); + + describe('Weather API (/api/weather)', () => { + const testCity = expectedCitiesList[0] || 'London'; + + it('returns weather data for a valid city', async () => { + const response = await request(app).get(`/api/weather?city=${testCity}`); + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('name'); + expect(response.body.name.toLowerCase()).toEqual(testCity.toLowerCase()); + expect(response.body).toHaveProperty('description'); + expect(response.body).toHaveProperty('temp'); + expect(response.body).toHaveProperty('feels_like'); + expect(response.body).toHaveProperty('wind_speed'); + expect(response.body).toHaveProperty('icon'); + }); + + it('returns 400 if city parameter is missing', async () => { + const response = await request(app).get('/api/weather'); + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('error', 'Missing city parameter'); + }); + }); + + describe('Cities API (/api/cities)', () => { + it('should return a 200 OK status', async () => { + const response = await request(app).get('/api/cities'); + expect(response.statusCode).toBe(200); + }); + + it('should return an array', async () => { + const response = await request(app).get('/api/cities'); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return the list of cities matching cities.json (or fallback if setup failed)', async () => { + const response = await request(app).get('/api/cities'); + expect(response.body).toEqual(expectedCitiesList); + }); + + it('should contain strings if the list is not empty', async () => { + const response = await request(app).get('/api/cities'); + if (response.body.length > 0) { // Covers the 'if' branch + expect(typeof response.body[0]).toBe('string'); + } else { // Covers the 'else' branch (e.g., if cities.json was empty) + expect(response.body.length).toBe(0); + } + }); + }); + + describe('Weather API error handling (fetch mocking)', () => { + let originalFetch; + beforeEach(() => { + originalFetch = global.fetch; + global.fetch = jest.fn(); + }); + afterEach(() => { + global.fetch = originalFetch; + }); + + it('handles non-OK response from OpenWeather (e.g., 404)', async () => { + // This mock covers the 'try' block inside the 'if (!response.ok)' + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: jest.fn().mockResolvedValue({ message: 'city not found' }), // Successfully parses OWM error + }); + const response = await request(app).get('/api/weather?city=FakeCity'); + expect(response.statusCode).toBe(404); + expect(response.body).toHaveProperty('error', 'city not found'); + }); + + it('handles non-OK response from OpenWeather with no message fallback', async () => { + // Specific test to ensure the || errorData.message fallback is hit + global.fetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: jest.fn().mockResolvedValue({}), // OWM error has no 'message' property + }); + const response = await request(app).get('/api/weather?city=FakeCityUnauthorized'); + expect(response.statusCode).toBe(401); + // Falls back to the initial 'Failed to fetch...' message + expect(response.body).toHaveProperty('error', 'Failed to fetch weather data'); + }); + + it('handles non-OK response from OpenWeather when JSON parsing fails', async () => { + // This mock covers the 'catch (parseError)' block inside the 'if (!response.ok)' + global.fetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', // This should be used as the error message + json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), + }); + const response = await request(app).get('/api/weather?city=AnotherFakeCity'); + expect(response.statusCode).toBe(503); + expect(response.body).toHaveProperty('error', 'Service Unavailable'); // Uses statusText + }); + + it('handles non-OK response from OpenWeather JSON parsing fails no statusText', async () => { + // Specific test to ensure the || errorData.message fallback is hit in the catch + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + // statusText: undefined, // No statusText provided + json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), + }); + const response = await request(app).get('/api/weather?city=YetAnotherFakeCity'); + expect(response.statusCode).toBe(500); + // Falls back to the initial 'Failed to fetch...' message + expect(response.body).toHaveProperty('error', 'Failed to fetch weather data'); + }); + + + it('handles fetch throwing a network error', async () => { + // This mock covers the main 'catch (err)' block of the '/api/weather' route + global.fetch.mockRejectedValue(new Error('Network error')); + const response = await request(app).get('/api/weather?city=NetworkErrorCity'); + expect(response.statusCode).toBe(500); + expect(response.body).toHaveProperty('error', 'Server error'); + }); + }); + +}); // End of 'Server Normal Operation' describe block + + +// --- Test Suite for Server Startup Failure --- +describe('Server Startup Error Handling', () => { + // This suite covers the 'catch (err)' block during city loading + let consoleErrorSpy; + let consoleWarnSpy; + + beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + beforeEach(() => { + jest.resetModules(); // IMPORTANT: Ensures server.js runs its top-level code again + }); + + afterEach(() => { + jest.unmock('fs'); // Ensure fs is unmocked after each test + }); + + it('should use fallback cities and log errors if cities.json is unreadable', async () => { + jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn((path, encoding) => { + if (path.endsWith('cities.json')) { + throw new Error('Mocked file read error'); + } + return jest.requireActual('fs').readFileSync(path, encoding); + }), + })); + + const { app } = require('../server'); // Require app *after* mock + const response = await request(app).get('/api/cities'); expect(response.statusCode).toBe(200); - expect(response.body).toHaveProperty('name', 'London'); - expect(response.body).toHaveProperty('description'); - expect(response.body).toHaveProperty('temp'); + expect(response.body).toEqual(fallbackCitiesList); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Error reading or parsing cities.json:'), expect.any(Error)); + expect(consoleWarnSpy).toHaveBeenCalledWith('Using fallback city list.'); }); - it('returns 400 if city is missing', async () => { - const response = await request(app).get('/api/weather'); - expect(response.statusCode).toBe(400); - expect(response.body).toHaveProperty('error', 'Missing city parameter'); + it('should use fallback cities and log errors if cities.json is invalid JSON', async () => { + jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn((path, encoding) => { + if (path.endsWith('cities.json')) { + return 'This is not valid JSON {'; // Invalid JSON + } + return jest.requireActual('fs').readFileSync(path, encoding); + }), + })); + + const { app } = require('../server'); // Require app *after* mock + const response = await request(app).get('/api/cities'); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(fallbackCitiesList); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Error reading or parsing cities.json:'), expect.any(SyntaxError)); + expect(consoleWarnSpy).toHaveBeenCalledWith('Using fallback city list.'); }); -}); +}); // End of 'Server Startup Error Handling' describe block + + +// --- Test Suite for Server Starting Logic --- +describe('Server Starting Function', () => { + // This suite covers the lines inside the 'startServer' function + let appListenSpy; + let consoleLogSpy; + let serverInstance; + // Import necessary parts *within* this scope using the DESTRUCTURED import + const { app, startServer, PORT } = require('../server'); -describe('Weather API error handling', () => { - // Before each test, spy on the global fetch (Node 18+) or require('node-fetch') if using that beforeEach(() => { - jest.spyOn(global, 'fetch'); // if on Node 18+, 'global.fetch' is the built-in + // Spy on app.listen and console.log + // Important: Spy on the 'app' instance imported from the module + appListenSpy = jest.spyOn(app, 'listen'); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Suppress log output }); afterEach(() => { - global.fetch.mockRestore(); // restore original fetch + // Restore original implementations + appListenSpy.mockRestore(); + consoleLogSpy.mockRestore(); + // Close the server if listen wasn't fully mocked and the server started + if (serverInstance && serverInstance.close) { + serverInstance.close((err) => { + if (err) console.error('Error closing server in test teardown:', err); + }); + } + serverInstance = null; }); - it('handles non-OK response from OpenWeather', async () => { - // Force fetch to return { ok: false, status: 404, ... } - global.fetch.mockResolvedValue({ - ok: false, - status: 404, - json: jest.fn().mockResolvedValue({}), + it('should call app.listen with the correct port', () => { + // Mock listen's implementation to avoid actual server start but record calls + appListenSpy.mockImplementation((port, callback) => { + // Return a mock server object with a close method + serverInstance = { close: jest.fn((cb) => { if(cb) cb(); }) }; // Assign to outer scope var + return serverInstance; }); - const response = await request(app).get('/api/weather?city=FakeCity'); - // The code sets status to response.status => 404 in this case - expect(response.statusCode).toBe(404); - expect(response.body).toHaveProperty('error', 'Failed to fetch weather data'); + startServer(PORT); // Call the exported function + + expect(appListenSpy).toHaveBeenCalledTimes(1); + expect(appListenSpy).toHaveBeenCalledWith(PORT, expect.any(Function)); }); - it('handles fetch throw (network error, etc.)', async () => { - // Force fetch to throw an error - global.fetch.mockRejectedValue(new Error('Network error')); + it('should log the correct message once listening', () => { + let capturedCallback; + // Mock listen to capture the callback + appListenSpy.mockImplementation((port, callback) => { + capturedCallback = callback; // Capture the callback + serverInstance = { close: jest.fn((cb) => { if(cb) cb(); }) }; // Assign to outer scope var + return serverInstance; + }); + + startServer(PORT); // Call the function + + // Manually execute the captured callback to simulate the 'listening' event + expect(capturedCallback).toEqual(expect.any(Function)); // Ensure callback was captured + if (capturedCallback) { + capturedCallback(); // Execute the callback + } else { + throw new Error('app.listen callback was not captured'); + } - const response = await request(app).get('/api/weather?city=FakeCity2'); - // The code should catch and return status 500, { error: 'Server error' } - expect(response.statusCode).toBe(500); - expect(response.body).toHaveProperty('error', 'Server error'); + // Check that console.log was called with the expected message + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith(`Server running on http://localhost:${PORT}`); }); }); +// --- END: New Test Suite for Server Starting Logic ---