diff --git a/package.json b/package.json index 87a0d2b..439e0d8 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "tdd": "./tdd.sh", - "start": "node app.js" - }, + "start": "node app.js", + "dev": "nodemon app.js", + "week3": "nodemon week-3-middleware/app.js" + }, "author": "Code the Dream", "license": "MIT", "dependencies": { diff --git a/tdd/assignment3a.test.js b/tdd/assignment3a.test.js new file mode 100644 index 0000000..101e226 --- /dev/null +++ b/tdd/assignment3a.test.js @@ -0,0 +1,86 @@ + +import { describe, it, expect, beforeAll } from "vitest"; +import httpMocks from "node-mocks-http"; +import { logon, register, logoff } from "../controllers/userController.js"; + +let saveRes = null; +let saveData = null; + +beforeAll(() => { + global.users = []; + global.user_id = null; +}); + +describe("testing logon, register, and logoff", () => { + it("You can register a user.", async () => { + const req = httpMocks.createRequest({ + method: "POST", + body: { + email: "jim@sample.com", + name: "Jim", + password: "Pa$$word20", + }, + }); + saveRes = httpMocks.createResponse(); + await register(req, saveRes); + expect(saveRes.statusCode).toBe(201); + }); + + it("The user can be logged on", async () => { + const req = httpMocks.createRequest({ + method: "POST", + body: { email: "jim@sample.com", password: "Pa$$word20" }, + }); + saveRes = httpMocks.createResponse(); + await logon(req, saveRes); + expect(saveRes.statusCode).toBe(200); + }); + + it("returns the expected name.", () => { + saveData = saveRes._getJSONData(); + expect(saveData.name).toBe("Jim"); + }); + + it("A logon attempt with a bad password returns a 401", async () => { + const req = httpMocks.createRequest({ + method: "POST", + body: { email: "jim@sample.com", password: "bad password" }, + }); + saveRes = httpMocks.createResponse(); + await logon(req, saveRes); + expect(saveRes.statusCode).toBe(401); + }); + + it("You can register an additional user.", async () => { + const req = httpMocks.createRequest({ + method: "POST", + body: { + email: "manuel@sample.com", + name: "Manuel", + password: "Pa$$word20", + }, + }); + saveRes = httpMocks.createResponse(); + await register(req, saveRes); + expect(saveRes.statusCode).toBe(201); + }); + + it("You can logon as that new user.", async () => { + const req = httpMocks.createRequest({ + method: "POST", + body: { email: "manuel@sample.com", password: "Pa$$word20" }, + }); + saveRes = httpMocks.createResponse(); + await logon(req, saveRes); + expect(saveRes.statusCode).toBe(200); + }); + + it("You can now logoff.", async () => { + const req = httpMocks.createRequest({ + method: "POST", + }); + saveRes = httpMocks.createResponse(); + await logoff(req, saveRes); + expect(saveRes.statusCode).toBe(200); + }); +}); diff --git a/tdd/assignment3b.test.js b/tdd/assignment3b.test.js new file mode 100644 index 0000000..94dabaf --- /dev/null +++ b/tdd/assignment3b.test.js @@ -0,0 +1,304 @@ + +import { + describe, + test, + expect, + beforeAll, + afterAll, + vi, +} from "vitest"; +import request from "supertest"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import app from "../week-3-middleware/app.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const MINIMAL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +let logSpy; +let errorSpy; +let warnSpy; + +beforeAll(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterAll(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); +}); + +describe("Assignment 3b: Middleware Integration", () => { + describe("Built-In Middleware", () => { + describe("JSON parsing middleware should parse request bodies for POST /adopt", () => { + let res; + + beforeAll(async () => { + res = await request(app) + .post("/adopt") + .send({ + dogName: "Sweet Pea", + name: "Ellen", + email: "ellen@codethedream.com", + }) + .set("Content-Type", "application/json"); + }); + + test("POST /adopt with valid JSON body responds with status 201", () => { + expect(res.status).toBe(201); + }); + + test("POST /adopt returns the expected success message", () => { + expect(res.body.message).toMatch( + /Adoption request received\. We will contact you at ellen@codethedream\.com for further details\./, + ); + }); + }); + + describe("Static file middleware should serve images from public/images directory", () => { + let res; + const imagePath = path.join( + __dirname, + "../week-3-middleware/public/images/dachshund.png", + ); + + beforeAll(() => { + if (!fs.existsSync(imagePath)) { + fs.mkdirSync(path.dirname(imagePath), { recursive: true }); + fs.writeFileSync(imagePath, MINIMAL_PNG); + } + }); + + beforeAll(async () => { + res = await request(app).get("/images/dachshund.png"); + }); + + test("GET /images/dachshund.png responds with status 200", () => { + expect(res.status).toBe(200); + }); + + test("GET /images/dachshund.png returns image/png content type", () => { + expect(res.headers["content-type"]).toMatch(/image\/png/); + }); + }); + }); + + describe("Custom Middleware", () => { + describe("Request ID middleware should add unique request ID to all requests", () => { + let res; + + beforeAll(async () => { + res = await request(app).get("/dogs"); + }); + + test("Response includes X-Request-Id header with unique request ID", () => { + expect(res.headers["x-request-id"]).toBeDefined(); + }); + }); + + describe("Logging middleware should log all requests with timestamp, method, path, and requestId", () => { + test("Logs requests in format [timestamp]: METHOD PATH (requestId)", async () => { + await request(app).get("/dogs"); + expect(logSpy).toHaveBeenCalledWith( + expect.stringMatching(/\[.*\]: GET \/dogs \(.+\)/), + ); + }); + }); + + describe("Error handling middleware should catch uncaught errors and return 500 with requestId", () => { + let res; + + beforeAll(async () => { + res = await request(app).get("/error"); + }); + + test("GET /error endpoint responds with status 500", () => { + expect(res.status).toBe(500); + }); + + test("Error response includes requestId in response body", () => { + expect(res.body.requestId).toBeDefined(); + }); + + test("Error response includes 'Internal Server Error' message", () => { + expect(res.body.error).toBe("Internal Server Error"); + }); + }); + }); + + describe("Enhanced Middleware Features", () => { + describe("Security headers middleware should set security headers on all responses", () => { + let res; + + beforeAll(async () => { + res = await request(app).get("/dogs"); + }); + + test("Response includes X-Content-Type-Options: nosniff header", () => { + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + }); + + test("Response includes X-Frame-Options: DENY header", () => { + expect(res.headers["x-frame-options"]).toBe("DENY"); + }); + + test("Response includes X-XSS-Protection: 1; mode=block header", () => { + expect(res.headers["x-xss-protection"]).toBe("1; mode=block"); + }); + }); + + describe("Request size limiting middleware should accept requests within the size limit", () => { + test("POST /adopt with request body within 1mb limit is accepted", async () => { + const res = await request(app) + .post("/adopt") + .send({ + dogName: "Sweet Pea", + name: "Test User", + email: "test@example.com", + }) + .set("Content-Type", "application/json"); + expect(res.status).toBe(201); + }); + }); + + describe("Content-Type validation middleware should reject POST requests without application/json content type", () => { + test("POST /adopt with text/plain content type returns 400 error", async () => { + const res = await request(app) + .post("/adopt") + .send( + JSON.stringify({ + dogName: "Sweet Pea", + name: "Test User", + email: "test@example.com", + }), + ) + .set("Content-Type", "text/plain"); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Content-Type must be application\/json/); + }); + + test("GET requests are not validated for content type", async () => { + const res = await request(app).get("/dogs"); + expect(res.status).toBe(200); + }); + }); + + describe("404 handler should return 404 JSON response for unmatched routes", () => { + let res; + + beforeAll(async () => { + res = await request(app).get("/nonexistent-route"); + }); + + test("Unmatched route responds with status 404", () => { + expect(res.status).toBe(404); + }); + + test("404 response includes 'Route not found' error message", () => { + expect(res.body.error).toBe("Route not found"); + }); + + test("404 response includes requestId in response body", () => { + expect(res.body.requestId).toBeDefined(); + }); + }); + }); + + describe("Advanced Error Handling", () => { + describe("Custom Error Classes", () => { + describe("A ValidationError (400) should be returned when POST /adopt is missing required fields (name, email, or dogName)", () => { + let res; + + beforeAll(async () => { + res = await request(app) + .post("/adopt") + .send({ dogName: "Sweet Pea" }) + .set("Content-Type", "application/json"); + }); + + test("POST /adopt with missing required fields responds with status 400", () => { + expect(res.status).toBe(400); + }); + + test("ValidationError response includes error message matching 'Missing required fields'", () => { + expect(res.body.error).toMatch(/Missing required fields/); + }); + + test("ValidationError response includes requestId in response body", () => { + expect(res.body.requestId).toBeDefined(); + }); + }); + + describe("A NotFoundError (404) should be returned when POST /adopt requests a dog that is not in the list or not available", () => { + let res; + + beforeAll(async () => { + res = await request(app) + .post("/adopt") + .send({ + dogName: "Nonexistent Dog", + name: "Test User", + email: "test@example.com", + }) + .set("Content-Type", "application/json"); + }); + + test("POST /adopt with nonexistent or unavailable dog responds with status 404", () => { + expect(res.status).toBe(404); + }); + + test("NotFoundError response includes error message matching 'not found or not available'", () => { + expect(res.body.error).toMatch(/not found or not available/); + }); + + test("NotFoundError response includes requestId in response body", () => { + expect(res.body.requestId).toBeDefined(); + }); + }); + + describe("Error logging should use console.warn() for 4xx errors and console.error() for 5xx errors", () => { + test("ValidationError (400) is logged with console.warn() and message starting with 'WARN: ValidationError'", async () => { + await request(app) + .post("/adopt") + .send({ dogName: "Sweet Pea" }) + .set("Content-Type", "application/json"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/WARN: ValidationError/), + expect.any(String), + ); + }); + + test("NotFoundError (404) is logged with console.warn() and message starting with 'WARN: NotFoundError'", async () => { + await request(app) + .post("/adopt") + .send({ + dogName: "Nonexistent Dog", + name: "Test User", + email: "test@example.com", + }) + .set("Content-Type", "application/json"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/WARN: NotFoundError/), + expect.any(String), + ); + }); + + test("Server errors (500) are logged with console.error() and message starting with 'ERROR: Error'", async () => { + await request(app).get("/error"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringMatching(/ERROR: Error/), + expect.any(String), + ); + }); + }); + }); + }); +}); diff --git a/week-3-middleware/app.js b/week-3-middleware/app.js new file mode 100644 index 0000000..b17a00e --- /dev/null +++ b/week-3-middleware/app.js @@ -0,0 +1,20 @@ +import express from "express"; +import { v4 as uuidv4 } from "uuid"; +import path from "path"; +import { fileURLToPath } from "url"; +import dogsRouter from "./routes/dogs.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); + + + +app.use("/", dogsRouter); // Do not remove this line + + +if (!process.env.VITEST) { + app.listen(3000, () => console.log("Server listening on port 3000")); +} + +export default app; + diff --git a/week-3-middleware/dogData.js b/week-3-middleware/dogData.js new file mode 100644 index 0000000..c112d3e --- /dev/null +++ b/week-3-middleware/dogData.js @@ -0,0 +1,54 @@ +export default [ + { + name: "Sweet Pea", + sex: "Female", + color: "Brown", + weight: "55 lbs", + breed: "Labrador Retriever", + age: 3, + story: "Rescued from a local shelter", + goodWithKids: true, + goodWithDogs: true, + goodWithCats: false, + status: "available", + }, + { + name: "Luna", + sex: "Female", + weight: "65 lbs", + color: "Black", + breed: "German Shepherd", + age: 5, + story: "Owner could no longer care for her", + goodWithKids: true, + goodWithDogs: false, + goodWithCats: false, + status: "available", + }, + { + name: "Max", + weight: "60 lbs", + sex: "Male", + color: "Golden", + breed: "Golden Retriever", + age: 2, + story: "Found as a stray", + goodWithKids: true, + goodWithDogs: true, + goodWithCats: true, + status: "pending", + }, + { + name: "Poppy", + weight: "9 lbs", + sex: "Female", + color: "Tricolor", + breed: "Dachshund", + age: 1, + story: "Rescued from a puppy mill", + goodWithKids: false, + goodWithDogs: true, + goodWithCats: false, + status: "available", + }, +]; diff --git a/week-3-middleware/public/images/dachshund.png b/week-3-middleware/public/images/dachshund.png new file mode 100644 index 0000000..a50c8ae Binary files /dev/null and b/week-3-middleware/public/images/dachshund.png differ diff --git a/week-3-middleware/routes/dogs.js b/week-3-middleware/routes/dogs.js new file mode 100644 index 0000000..c26b758 --- /dev/null +++ b/week-3-middleware/routes/dogs.js @@ -0,0 +1,40 @@ +import express from "express"; +import dogData from "../dogData.js"; + +const router = express.Router(); + +router.get("/dogs", (req, res) => { + res.json(dogData); +}); + +router.post("/adopt", (req, res, next) => { + const { name, address, email, dogName } = req.body; + + + // Throw a ValidationError + + + + const dog = dogData.find((d) => d.name === dogName && d.status === "available"); +// Throw a NotFoundError + + + + + return res.status(201).json({ + message: `Adoption request received. We will contact you at ${email} for further details.`, + application: { + name, + address, + email, + dogName, + applicationId: Date.now(), + }, + }); +}); + +router.get("/error", (req, res, next) => { + next(new Error("Test error")); +}); + +export default router;