From 9823b7a684fc4a3ca4e81a2230eadf3e2f84e8d1 Mon Sep 17 00:00:00 2001 From: Martin Martinov Date: Thu, 26 Mar 2026 18:59:12 +0200 Subject: [PATCH] feat: characterization tests and extract domain modules for flight booking API Add characterization tests capturing existing behavior of all flight and booking endpoints, then extract domain logic into focused modules (pricing, search-flights, create/update/cancel-booking) behind repository ports with in-memory adapters. --- .../.claude/commands/characterize.md | 1 + .../bookings-behavior-catalog.md | 180 ++++++ .../bookings.characterization.test.ts | 560 ++++++++++++++++++ .../flights-behavior-catalog.md | 113 ++++ .../flights.characterization.test.ts | 330 +++++++++++ .../requests.sh | 33 ++ .../adapters/in-memory-booking-repository.ts | 29 + .../adapters/in-memory-flight-repository.ts | 48 ++ .../src/domain/cancel-booking.ts | 40 ++ .../src/domain/create-booking.ts | 68 +++ .../src/domain/pricing.ts | 85 +++ .../src/domain/search-flights.ts | 21 + .../src/domain/types.ts | 52 ++ .../src/domain/update-booking.ts | 57 ++ .../src/flight-booking-api.ts | 405 ++----------- .../src/ports/booking-repository.ts | 9 + .../src/ports/flight-repository.ts | 9 + 17 files changed, 1671 insertions(+), 369 deletions(-) create mode 100644 session-2/1-characterization-refactoring/__characterization__/bookings-behavior-catalog.md create mode 100644 session-2/1-characterization-refactoring/__characterization__/bookings.characterization.test.ts create mode 100644 session-2/1-characterization-refactoring/__characterization__/flights-behavior-catalog.md create mode 100644 session-2/1-characterization-refactoring/__characterization__/flights.characterization.test.ts create mode 100755 session-2/1-characterization-refactoring/requests.sh create mode 100644 session-2/1-characterization-refactoring/src/adapters/in-memory-booking-repository.ts create mode 100644 session-2/1-characterization-refactoring/src/adapters/in-memory-flight-repository.ts create mode 100644 session-2/1-characterization-refactoring/src/domain/cancel-booking.ts create mode 100644 session-2/1-characterization-refactoring/src/domain/create-booking.ts create mode 100644 session-2/1-characterization-refactoring/src/domain/pricing.ts create mode 100644 session-2/1-characterization-refactoring/src/domain/search-flights.ts create mode 100644 session-2/1-characterization-refactoring/src/domain/types.ts create mode 100644 session-2/1-characterization-refactoring/src/domain/update-booking.ts create mode 100644 session-2/1-characterization-refactoring/src/ports/booking-repository.ts create mode 100644 session-2/1-characterization-refactoring/src/ports/flight-repository.ts diff --git a/session-2/1-characterization-refactoring/.claude/commands/characterize.md b/session-2/1-characterization-refactoring/.claude/commands/characterize.md index 4e3e8d0..398e9cc 100644 --- a/session-2/1-characterization-refactoring/.claude/commands/characterize.md +++ b/session-2/1-characterization-refactoring/.claude/commands/characterize.md @@ -33,6 +33,7 @@ Do not modify production code. ### 3. Behavior Catalog (before writing any tests) For each entry point, list: + - Happy-path behavior - Edge/boundary behavior (systematically derived from conditionals, switch statements, type guards, and default branches in the code) diff --git a/session-2/1-characterization-refactoring/__characterization__/bookings-behavior-catalog.md b/session-2/1-characterization-refactoring/__characterization__/bookings-behavior-catalog.md new file mode 100644 index 0000000..1b21c95 --- /dev/null +++ b/session-2/1-characterization-refactoring/__characterization__/bookings-behavior-catalog.md @@ -0,0 +1,180 @@ +# Behavior Catalog — Booking Endpoints + +Source: `src/flight-booking-api.ts` + +--- + +## 1. POST /bookings — Create Booking (lines 41–164) + +### Validation +- [x] Missing `flightId` → 400 "Flight ID is required" +- [x] Missing `passengerName` → 400 "Passenger name is required" +- [x] Missing `passengerEmail` → 400 "Passenger email is required" +- [x] Email without `@` → 400 "Invalid email format" +- [x] Email without `.` → 400 "Invalid email format" +- [x] `"@."` passes email validation (quirk) +- [x] Non-existent flight ID → 404 "Flight not found" +- [x] Flight with 0 seats → 400 "No seats available" +- [x] Invalid seat class → 400 "Invalid seat class" +- [x] Missing `seatClass` defaults to `'economy'` + +### Pricing +- [x] Economy: base × dynamic × seasonal × 1.0 +- [x] Premium: base × dynamic × seasonal × 1.5 +- [x] Business: base × dynamic × seasonal × 2.5 +- [x] Baggage: +$25 per bag +- [x] Missing baggage treated as 0 +- [x] SUMMER10 discount (10%) +- [x] WINTER20 discount (20%) +- [x] EARLYBIRD discount (15%) +- [x] STUDENT discount (25%) +- [x] Invalid discount code silently ignored (quirk) +- [x] Discount applied AFTER baggage fees are added (quirk) +- [x] Winter seasonal pricing (December flight) + +### Response & Side Effects +- [x] Returns 201 with full booking object +- [x] Booking ID matches pattern `BK-{timestamp}-{counter}` +- [x] `bookedAt` is valid ISO 8601 string +- [x] `discountCode` is null when not provided +- [x] Flight seats decremented by 1 +- [x] Unique IDs with incrementing counter + +## 2. GET /bookings/:id (lines 167–177) + +- [x] Booking found → 200 with full booking object +- [x] Booking not found → 404 "Booking not found" +- [x] Returned object matches creation response exactly + +## 3. DELETE /bookings/:id — Cancel (lines 180–231) + +### Validation +- [x] Booking not found → 404 "Booking not found" +- [x] Already cancelled → 400 "Booking already cancelled" + +### Refund Policy +- [x] ≤ 24h since booking → 100% refund +- [x] Exactly 24h since booking → 100% refund (boundary) +- [x] > 24h since booking AND > 7 days until flight → 80% refund +- [x] > 24h since booking AND ≤ 7 days until flight → $0 refund + +### Response & Side Effects +- [x] Response shape: `{ message, refundAmount, booking }` +- [x] `booking.status` set to `'cancelled'` +- [x] `booking.cancelledAt` is valid ISO 8601 string +- [x] `booking.refundAmount` matches top-level `refundAmount` +- [x] Flight seats restored (+1) + +## 4. PUT /bookings/:id — Update (lines 234–341) + +### Validation +- [x] Booking not found → 404 "Booking not found" +- [x] Cancelled booking → 400 "Cannot modify cancelled booking" +- [x] Invalid seat class → 400 "Invalid seat class" + +### Seat Class Changes +- [x] Economy → premium: price increases +- [x] Economy → business: price increases +- [x] Premium → economy: price decreases +- [x] Same seat class sent → no change, no `updatedAt` + +### Baggage Changes +- [x] Add bags → price increases ($25/bag) +- [x] Remove bags → price decreases ($25/bag) +- [x] Same baggage count → no change, no `updatedAt` + +### Combined & Edge Cases +- [x] Both seat class + baggage in one request → both applied +- [x] Empty body → returns booking unchanged, no `updatedAt` +- [x] `updatedAt` set only when price changes +- [x] Seat class price diff uses CURRENT seat availability, not booking-time (quirk) + +--- + +## Characterized Quirks / Suspected Bugs + +1. **`"@."` passes email validation** (`flight-booking-api.ts:60`): + `!email.includes('@') || !email.includes('.')` only checks for presence of both + characters — no structural validation. Any string with `@` and `.` passes. + // NOTE: possible bug (characterized intentionally) + +2. **Invalid discount code silently ignored** (`flight-booking-api.ts:128`): + `if (DISCOUNT_CODES[code])` is falsy for unknown codes, so the discount block is + skipped. The response still includes `discountCode: "BOGUS"` with full price. + // NOTE: possible bug (characterized intentionally) + +3. **Discount applied after baggage fees** (`flight-booking-api.ts:123-131`): + `total_price = price_after_seat + baggage_fee`, then discount on `total_price`. + Discount reduces baggage cost too. May or may not be intentional. + // NOTE: possible bug (characterized intentionally) + +4. **Seat class update uses current availability** (`flight-booking-api.ts:271`): + When upgrading seat class on an existing booking, the price difference is calculated + using the flight's CURRENT seat count (dynamic pricing), not the availability at + original booking time. If seats were sold since, the multiplier may differ, leading + to a different upgrade cost than expected. + // NOTE: possible bug (characterized intentionally) + +5. **Flight not found during update → 500** (`flight-booking-api.ts:265`): + Returns HTTP 500 "Flight not found" instead of 404. This path is difficult to + trigger since a valid booking always references an existing flight, but a data + inconsistency could hit it. + // NOTE: possible bug (characterized intentionally) + +## Untested Paths + +| Path | Reason | What's Needed | +|------|--------|---------------| +| Flight-not-found on PUT update (500 path) | Cannot trigger without data inconsistency — booking creation validates flight exists | Would need to delete a flight after booking (no API exists for this) | +| Refund at exactly 7 days before flight boundary | Complex time setup with narrow window | Could add with more precise fake timer setup | +| Negative baggage count | Not tested — `baggage: -1` would yield negative fee, reducing price | Add test if business logic matters | +| Concurrent bookings race condition | In-memory state has no locking | Would need parallel request harness | + +## Determinism Controls Used + +- **State reset**: `POST /reset` in `beforeEach` restores seed data every test +- **Fake timers**: `jest.useFakeTimers()` with `jest.setSystemTime()` for refund tier + tests — only `Date`/`Date.now()` are faked; `setImmediate`/`nextTick` left real to + avoid interfering with Express/Node event loop +- **Nondeterministic fields**: Booking IDs asserted with regex `/^BK-\d+-\d+$/`; + timestamps asserted as valid ISO strings, not exact values +- **No ordering dependency**: Each test creates its own bookings from clean state + +## Mocking Decisions + +**No mocks**. All endpoints operate on in-memory state. `jest.useFakeTimers()` is +used to control `Date.now()` for refund calculation — this is nondeterminism control, +not mocking an external dependency. + +## Sensitivity Check Results + +| # | Mutation | Location | Tests Failed | Detected? | +|---|----------|----------|-------------|-----------| +| 1 | Business seat multiplier 2.5 → 3.0 | line 110 | 1 (business price) | Yes | +| 2 | Refund 24h threshold → 12h | line 205 | 1 (24h boundary) | Yes | +| 3 | Baggage fee $25 → $30 | line 121 | 3 (baggage, discount+baggage, bag removal) | Yes | + +## Baseline Snapshot + +``` +Test Suites: 1 passed, 1 total +Tests: 52 passed, 52 total +Snapshots: 0 total +Time: ~1.6s +``` + +## Refactor Gate Recommendation + +**All 52 tests should be mandatory in CI before any booking refactor lands.** + +Priority groups by risk: + +1. **Pricing tests (12)** — highest risk: money calculations, discount logic, + seat class multipliers. Any regression here means wrong charges. +2. **Refund policy tests (4)** — high risk: time-dependent refund tiers with + real money implications. Fake timer tests are critical. +3. **Validation tests (10)** — medium risk: protect API contract and error codes. +4. **Update tests (11)** — medium risk: complex price recalculation with current + availability. The "current availability quirk" test is especially important. +5. **Side effect tests (5)** — seat count management, booking persistence. +6. **GET/basic tests (10)** — lower risk but quick to run, ensure CRUD works. diff --git a/session-2/1-characterization-refactoring/__characterization__/bookings.characterization.test.ts b/session-2/1-characterization-refactoring/__characterization__/bookings.characterization.test.ts new file mode 100644 index 0000000..0ea8285 --- /dev/null +++ b/session-2/1-characterization-refactoring/__characterization__/bookings.characterization.test.ts @@ -0,0 +1,560 @@ +/** + * Characterization tests for the booking endpoints: + * POST /bookings + * GET /bookings/:id + * DELETE /bookings/:id + * PUT /bookings/:id + * + * These tests lock externally observable behavior as a safety net for refactors. + * Assertions encode *observed* behavior, including quirks. + */ + +import request from 'supertest'; +import { app, server } from '../src/flight-booking-api'; + +afterAll((done) => { + server.close(done); +}); + +beforeEach(async () => { + await request(app).post('/reset'); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const VALID_BOOKING = { + flightId: 'FL001', // LON→NYC, 2024-06-15, 150 seats, base $450 + passengerName: 'Jane Doe', + passengerEmail: 'jane@example.com', + seatClass: 'economy', +}; + +async function createBooking(overrides: Record = {}) { + return request(app) + .post('/bookings') + .send({ ...VALID_BOOKING, ...overrides }); +} + +// =================================================================== +// POST /bookings — Validation +// =================================================================== + +describe('POST /bookings — validation', () => { + it('returns 400 when flightId is missing', async () => { + const res = await createBooking({ flightId: undefined }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Flight ID is required' }); + }); + + it('returns 400 when passengerName is missing', async () => { + const res = await createBooking({ passengerName: undefined }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Passenger name is required' }); + }); + + it('returns 400 when passengerEmail is missing', async () => { + const res = await createBooking({ passengerEmail: undefined }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Passenger email is required' }); + }); + + it('returns 400 when email has no @ sign', async () => { + const res = await createBooking({ passengerEmail: 'jane.example.com' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid email format' }); + }); + + it('returns 400 when email has no dot', async () => { + const res = await createBooking({ passengerEmail: 'jane@example' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid email format' }); + }); + + // NOTE: possible bug (characterized intentionally) — "@." passes email validation + it('accepts "@." as a valid email', async () => { + const res = await createBooking({ passengerEmail: '@.' }); + expect(res.status).toBe(201); + }); + + it('returns 404 when flight does not exist', async () => { + const res = await createBooking({ flightId: 'NOPE' }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Flight not found' }); + }); + + it('returns 400 when no seats available', async () => { + // FL003 has 80 seats — book them all, then try one more + for (let i = 0; i < 80; i++) { + await createBooking({ + flightId: 'FL003', + passengerEmail: `fill${i}@test.com`, + }); + } + const res = await createBooking({ + flightId: 'FL003', + passengerEmail: 'overflow@test.com', + }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'No seats available' }); + }); + + it('returns 400 for invalid seat class', async () => { + const res = await createBooking({ seatClass: 'first' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid seat class' }); + }); + + it('defaults seatClass to economy when not provided', async () => { + const res = await createBooking({ seatClass: undefined }); + expect(res.status).toBe(201); + expect(res.body.seatClass).toBe('economy'); + }); +}); + +// =================================================================== +// POST /bookings — Pricing +// =================================================================== + +describe('POST /bookings — pricing', () => { + // FL001: base $450, dynamic ×1.0 (150 seats), seasonal ×1.2 (June) + // Economy base: 450 * 1.0 * 1.2 = $540 + + it('calculates economy price correctly', async () => { + const res = await createBooking({ seatClass: 'economy' }); + expect(res.body.price).toBe(540); + }); + + it('calculates premium price (×1.5 seat multiplier)', async () => { + const res = await createBooking({ seatClass: 'premium' }); + expect(res.body.price).toBe(810); + }); + + it('calculates business price (×2.5 seat multiplier)', async () => { + const res = await createBooking({ seatClass: 'business' }); + expect(res.body.price).toBe(1350); + }); + + it('adds $25 per bag for baggage', async () => { + const res = await createBooking({ baggage: 2 }); + // 540 + (2 × 25) = 590 + expect(res.body.price).toBe(590); + expect(res.body.baggage).toBe(2); + }); + + it('treats missing baggage as 0', async () => { + const res = await createBooking({}); + expect(res.body.baggage).toBe(0); + expect(res.body.price).toBe(540); + }); + + it('applies SUMMER10 discount (10%)', async () => { + const res = await createBooking({ discountCode: 'SUMMER10' }); + // 540 * 0.90 = 486 + expect(res.body.price).toBe(486); + }); + + it('applies WINTER20 discount (20%)', async () => { + const res = await createBooking({ discountCode: 'WINTER20' }); + // 540 * 0.80 = 432 + expect(res.body.price).toBe(432); + }); + + it('applies EARLYBIRD discount (15%)', async () => { + const res = await createBooking({ discountCode: 'EARLYBIRD' }); + // 540 * 0.85 = 459 + expect(res.body.price).toBe(459); + }); + + it('applies STUDENT discount (25%)', async () => { + const res = await createBooking({ discountCode: 'STUDENT' }); + // 540 * 0.75 = 405 + expect(res.body.price).toBe(405); + }); + + // NOTE: possible bug (characterized intentionally) — invalid discount code silently ignored + it('silently ignores invalid discount code (no error, no discount)', async () => { + const res = await createBooking({ discountCode: 'BOGUS' }); + expect(res.status).toBe(201); + expect(res.body.price).toBe(540); + expect(res.body.discountCode).toBe('BOGUS'); + }); + + // NOTE: possible bug (characterized intentionally) — discount applied AFTER baggage fees + it('applies discount to total including baggage fees', async () => { + const res = await createBooking({ baggage: 2, discountCode: 'SUMMER10' }); + // (540 + 50) * 0.90 = 531 + expect(res.body.price).toBe(531); + }); + + it('uses winter seasonal pricing for December flight', async () => { + // FL009: LON→NYC, 2024-12-25, 150 seats, base $450 + // 450 * 1.0 * 1.3 = 585 + const res = await createBooking({ flightId: 'FL009' }); + expect(res.body.price).toBe(585); + }); +}); + +// =================================================================== +// POST /bookings — Response shape and side effects +// =================================================================== + +describe('POST /bookings — response & side effects', () => { + it('returns 201 with all expected fields', async () => { + const res = await createBooking(); + expect(res.status).toBe(201); + + const b = res.body; + expect(b.id).toMatch(/^BK-\d+-\d+$/); + expect(b.flightId).toBe('FL001'); + expect(b.flight).toEqual({ from: 'LON', to: 'NYC', date: '2024-06-15' }); + expect(b.passengerName).toBe('Jane Doe'); + expect(b.passengerEmail).toBe('jane@example.com'); + expect(b.seatClass).toBe('economy'); + expect(b.baggage).toBe(0); + expect(b.price).toBe(540); + expect(b.discountCode).toBeNull(); + expect(b.status).toBe('confirmed'); + expect(new Date(b.bookedAt).toISOString()).toBe(b.bookedAt); + }); + + it('stores discountCode in response even when null', async () => { + const res = await createBooking({}); + expect(res.body.discountCode).toBeNull(); + }); + + it('decrements flight seats by 1 per booking', async () => { + await createBooking(); + // Check via the /flights search endpoint + const search = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + expect(search.body.flights[0].seatsAvailable).toBe(149); + }); + + it('assigns unique booking IDs with incrementing counter', async () => { + const r1 = await createBooking({ passengerEmail: 'a@b.com' }); + const r2 = await createBooking({ passengerEmail: 'c@d.com' }); + expect(r1.body.id).not.toBe(r2.body.id); + // Both match the pattern + expect(r1.body.id).toMatch(/^BK-\d+-\d+$/); + expect(r2.body.id).toMatch(/^BK-\d+-\d+$/); + }); +}); + +// =================================================================== +// GET /bookings/:id +// =================================================================== + +describe('GET /bookings/:id', () => { + it('returns the booking when found', async () => { + const created = await createBooking(); + const id = created.body.id; + + const res = await request(app).get(`/bookings/${id}`); + expect(res.status).toBe(200); + expect(res.body.id).toBe(id); + expect(res.body.passengerName).toBe('Jane Doe'); + }); + + it('returns 404 when booking does not exist', async () => { + const res = await request(app).get('/bookings/BK-0-0'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Booking not found' }); + }); + + it('returns the full booking object unchanged', async () => { + const created = await createBooking(); + const fetched = await request(app).get(`/bookings/${created.body.id}`); + expect(fetched.body).toEqual(created.body); + }); +}); + +// =================================================================== +// DELETE /bookings/:id — Cancel booking +// =================================================================== + +describe('DELETE /bookings/:id — basic', () => { + it('returns 404 when booking does not exist', async () => { + const res = await request(app).delete('/bookings/BK-0-0'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Booking not found' }); + }); + + it('returns 400 when booking is already cancelled', async () => { + const created = await createBooking(); + await request(app).delete(`/bookings/${created.body.id}`); + const res = await request(app).delete(`/bookings/${created.body.id}`); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Booking already cancelled' }); + }); + + it('returns correct response shape', async () => { + const created = await createBooking(); + const res = await request(app).delete(`/bookings/${created.body.id}`); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Booking cancelled'); + expect(typeof res.body.refundAmount).toBe('number'); + expect(res.body.booking.status).toBe('cancelled'); + expect( + new Date(res.body.booking.cancelledAt).toISOString() + ).toBe(res.body.booking.cancelledAt); + }); + + it('restores 1 flight seat on cancellation', async () => { + const created = await createBooking(); + // After booking: 149 seats + await request(app).delete(`/bookings/${created.body.id}`); + // After cancel: 150 seats restored + const search = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + expect(search.body.flights[0].seatsAvailable).toBe(150); + }); + + it('sets refundAmount on the booking object', async () => { + const created = await createBooking(); + const res = await request(app).delete(`/bookings/${created.body.id}`); + expect(res.body.booking.refundAmount).toBe(res.body.refundAmount); + }); +}); + +// =================================================================== +// DELETE /bookings/:id — Refund policy (time-dependent) +// =================================================================== + +describe('DELETE /bookings/:id — refund tiers', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['setImmediate', 'clearImmediate', 'nextTick'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('gives full refund when cancelled within 24 hours of booking', async () => { + jest.setSystemTime(new Date('2024-06-01T00:00:00Z')); + const created = await createBooking(); + // 12 hours later — still within 24h window + jest.setSystemTime(new Date('2024-06-01T12:00:00Z')); + const res = await request(app).delete(`/bookings/${created.body.id}`); + expect(res.body.refundAmount).toBe(created.body.price); + }); + + it('gives 80% refund when > 24h since booking AND > 7 days until flight', async () => { + // FL001 flies 2024-06-15 + jest.setSystemTime(new Date('2024-06-01T00:00:00Z')); + const created = await createBooking(); // price = 540 + + // 25 hours later — past 24h window, but 13+ days until flight + jest.setSystemTime(new Date('2024-06-02T01:00:00Z')); + const res = await request(app).delete(`/bookings/${created.body.id}`); + // 540 * 0.8 = 432 + expect(res.body.refundAmount).toBe(432); + }); + + it('gives $0 refund when > 24h since booking AND ≤ 7 days until flight', async () => { + // FL001 flies 2024-06-15 + jest.setSystemTime(new Date('2024-06-10T00:00:00Z')); + const created = await createBooking(); // price = 540 + + // 36 hours later — past 24h window, only ~3.5 days until flight + jest.setSystemTime(new Date('2024-06-11T12:00:00Z')); + const res = await request(app).delete(`/bookings/${created.body.id}`); + expect(res.body.refundAmount).toBe(0); + }); + + it('gives full refund at exactly 24 hours since booking', async () => { + jest.setSystemTime(new Date('2024-06-01T00:00:00Z')); + const created = await createBooking(); + + // Exactly 24 hours later — boundary: hours_since_booking <= 24 + jest.setSystemTime(new Date('2024-06-02T00:00:00Z')); + const res = await request(app).delete(`/bookings/${created.body.id}`); + expect(res.body.refundAmount).toBe(created.body.price); + }); +}); + +// =================================================================== +// PUT /bookings/:id — Update booking +// =================================================================== + +describe('PUT /bookings/:id — validation', () => { + it('returns 404 when booking does not exist', async () => { + const res = await request(app) + .put('/bookings/BK-0-0') + .send({ seatClass: 'premium' }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Booking not found' }); + }); + + it('returns 400 when trying to update a cancelled booking', async () => { + const created = await createBooking(); + await request(app).delete(`/bookings/${created.body.id}`); + + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'premium' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Cannot modify cancelled booking' }); + }); + + it('returns 400 for invalid seat class', async () => { + const created = await createBooking(); + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'first' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid seat class' }); + }); +}); + +describe('PUT /bookings/:id — seat class changes', () => { + // FL001 after 1 booking: 149 seats (≥100 → dynamic ×1.0), June → seasonal ×1.2 + // base_with_multipliers = 450 * 1.0 * 1.2 = 540 + + it('upgrades economy → premium (price increases)', async () => { + const created = await createBooking({ seatClass: 'economy' }); // $540 + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'premium' }); + // diff = (540 * 1.5) - (540 * 1.0) = 810 - 540 = 270 + // new = 540 + 270 = 810 + expect(res.status).toBe(200); + expect(res.body.seatClass).toBe('premium'); + expect(res.body.price).toBe(810); + }); + + it('upgrades economy → business (price increases)', async () => { + const created = await createBooking({ seatClass: 'economy' }); // $540 + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'business' }); + // diff = (540 * 2.5) - (540 * 1.0) = 1350 - 540 = 810 + // new = 540 + 810 = 1350 + expect(res.body.seatClass).toBe('business'); + expect(res.body.price).toBe(1350); + }); + + it('downgrades premium → economy (price decreases)', async () => { + const created = await createBooking({ seatClass: 'premium' }); // $810 + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'economy' }); + // diff = (540 * 1.0) - (540 * 1.5) = 540 - 810 = -270 + // new = 810 + (-270) = 540 + expect(res.body.seatClass).toBe('economy'); + expect(res.body.price).toBe(540); + }); + + it('does not change price when same seat class is sent', async () => { + const created = await createBooking({ seatClass: 'economy' }); + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'economy' }); + expect(res.body.price).toBe(created.body.price); + expect(res.body.updatedAt).toBeUndefined(); + }); +}); + +describe('PUT /bookings/:id — baggage changes', () => { + it('increases price when adding bags', async () => { + const created = await createBooking({ baggage: 0 }); // $540 + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ baggage: 2 }); + // 540 + (2 * 25) = 590 + expect(res.body.baggage).toBe(2); + expect(res.body.price).toBe(590); + }); + + it('decreases price when removing bags', async () => { + const created = await createBooking({ baggage: 3 }); // 540 + 75 = 615 + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ baggage: 1 }); + // 615 + (1 - 3) * 25 = 615 - 50 = 565 + expect(res.body.baggage).toBe(1); + expect(res.body.price).toBe(565); + }); + + it('does not change price when same baggage count is sent', async () => { + const created = await createBooking({ baggage: 1 }); + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ baggage: 1 }); + expect(res.body.price).toBe(created.body.price); + expect(res.body.updatedAt).toBeUndefined(); + }); +}); + +describe('PUT /bookings/:id — combined & edge cases', () => { + it('applies both seat class and baggage changes in one request', async () => { + const created = await createBooking({ seatClass: 'economy', baggage: 0 }); // $540 + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'premium', baggage: 2 }); + // Seat diff: 810 - 540 = 270 → 540 + 270 = 810 + // Bag diff: 2 * 25 = 50 → 810 + 50 = 860 + expect(res.body.seatClass).toBe('premium'); + expect(res.body.baggage).toBe(2); + expect(res.body.price).toBe(860); + }); + + it('returns booking unchanged when body is empty', async () => { + const created = await createBooking(); + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({}); + expect(res.status).toBe(200); + expect(res.body.price).toBe(created.body.price); + expect(res.body.seatClass).toBe('economy'); + expect(res.body.updatedAt).toBeUndefined(); + }); + + it('sets updatedAt only when price changes', async () => { + const created = await createBooking(); + expect(created.body.updatedAt).toBeUndefined(); + + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'premium' }); + expect(res.body.updatedAt).toBeDefined(); + expect(new Date(res.body.updatedAt).toISOString()).toBe(res.body.updatedAt); + }); + + // NOTE: possible bug (characterized intentionally) — price diff uses CURRENT seat + // availability, not availability at booking time. If seats have been sold since, + // the dynamic multiplier may differ. + it('recalculates seat class price diff using current seat availability', async () => { + // FL003: 80 seats, base $120, dynamic ×1.1, seasonal ×1.2 + // Book 31 seats to drop to 49 (×1.3 tier) AFTER creating original booking + const created = await createBooking({ + flightId: 'FL003', + seatClass: 'economy', + }); + // Price at booking: 120 * 1.1 * 1.2 = 158.40 (80 seats, ×1.1 tier) + expect(created.body.price).toBe(158.4); + + // Book 31 more to drop FL003 to 48 seats (×1.3 tier) + for (let i = 0; i < 31; i++) { + await createBooking({ + flightId: 'FL003', + passengerEmail: `fill${i}@test.com`, + }); + } + + // Now upgrade original booking — price diff uses CURRENT availability (48 seats, ×1.3) + const res = await request(app) + .put(`/bookings/${created.body.id}`) + .send({ seatClass: 'premium' }); + + // base_w_dynamic_seasonal at update time: 120 * 1.3 * 1.2 = 187.2 + // old_seat_price = 187.2 * 1.0 = 187.2 + // new_seat_price = 187.2 * 1.5 = 280.8 + // diff = 280.8 - 187.2 = 93.6 + // new_price = 158.40 + 93.6 = 252 + expect(res.body.price).toBe(252); + }); +}); diff --git a/session-2/1-characterization-refactoring/__characterization__/flights-behavior-catalog.md b/session-2/1-characterization-refactoring/__characterization__/flights-behavior-catalog.md new file mode 100644 index 0000000..2c59bdf --- /dev/null +++ b/session-2/1-characterization-refactoring/__characterization__/flights-behavior-catalog.md @@ -0,0 +1,113 @@ +# Behavior Catalog — GET /flights + +Entry point: `GET /flights?from=X&to=Y&date=YYYY-MM-DD` +Source: `src/flight-booking-api.ts:43-96` + +## 1. Validation (line 48-50) + +- [x] Missing all three params → 400 `{ error: "Missing required parameters: from, to, date" }` +- [x] Missing `from` only → same 400 +- [x] Missing `to` only → same 400 +- [x] Missing `date` only → same 400 + +## 2. Filtering (lines 52-57) + +- [x] Matching route + date with seats > 0 → returns flight(s) +- [x] Non-matching route → `{ flights: [] }` (200) +- [x] Non-matching date → `{ flights: [] }` (200) +- [x] Case-sensitive codes (`lon` ≠ `LON`) → `{ flights: [] }` +- [x] Flight with exactly 1 seat remaining → still returned +- [x] Flight with 0 seats → excluded from results + +## 3. Response shape (lines 85-95) + +- [x] Each flight object has exactly: `id`, `from`, `to`, `date`, `seatsAvailable`, `price` +- [x] `seatsAvailable` reflects current seat count (not base) +- [x] `price` is a number (not string) + +## 4. Seasonal pricing (lines 75-83) + +- [x] June flight → base * dynamic * 1.2 (summer) +- [x] July flight → base * dynamic * 1.2 (summer) +- [x] August flight → base * dynamic * 1.2 (summer) +- [x] December flight → base * dynamic * 1.3 (winter) +- [ ] January flight — **untested**: no January flight in seed data; would need production code change to add one + +## 5. Dynamic pricing — seat-availability tiers (lines 65-72) + +- [x] >= 100 seats → no multiplier (×1.0) +- [x] 50-99 seats → ×1.1 (FL003 starts at 80) +- [x] 20-49 seats → ×1.3 (reduced FL003 to 49 via bookings) +- [x] < 20 seats → ×1.5 (reduced FL003 to 19 via bookings) + +## 6. Price rounding (line 91) + +- [x] Price rounded to 2 decimal places via `Math.round(price * 100) / 100` + +## 7. Snapshot regression + +- [x] Full response snapshot: LON→NYC 2024-06-15 (summer, >= 100 seats) +- [x] Full response snapshot: LON→NYC 2024-12-25 (winter, >= 100 seats) +- [x] Full response snapshot: LON→PAR 2024-06-20 (summer, 50-99 seat tier) + +--- + +## Characterized Quirks / Suspected Bugs + +1. **No "not found" vs "no match" distinction** (`flight-booking-api.ts:95`): A search with + a nonexistent airport code (e.g., `XXX`) returns 200 `{ flights: [] }` — same as a valid + code with no flights. Consumers cannot distinguish "bad input" from "no availability." + // NOTE: possible bug (characterized intentionally) — no input validation on airport codes + +2. **Duplicated pricing logic** (`flight-booking-api.ts:60-92` and `:133-192`): The dynamic + + seasonal pricing calculation is copy-pasted between `/flights` and `/bookings`. A refactor + that changes one but not the other will cause price discrepancies between search and booking. + +## Untested Paths + +| Path | Reason | What's Needed | +|------|--------|---------------| +| January seasonal pricing (month === 1) | No January flight in seed data | Add a seed flight with a January date (requires production code change) or expose a way to add flights at runtime | +| Non-seasonal months (e.g., March, April) | No seed flights exist for months 2-5 or 9-11 | Same as above | +| Multiple flights returned for same route+date | All seed data has unique route+date combos | Same as above | + +## Determinism Controls Used + +- **State reset**: `POST /reset` called in `beforeEach` to restore seed data before every test +- **No time/random/locale dependency**: The `/flights` endpoint uses only `new Date(f.date)` on + fixed seed data — no `Date.now()`, no random values, no locale-sensitive formatting +- **No external dependencies**: All data is in-memory; no DB, network, or filesystem calls + +## Mocking Decisions + +No mocks were used. The endpoint operates entirely on in-memory state, and `supertest` drives +the Express app without a real network socket. The `/reset` endpoint serves as the fixture +mechanism. + +## Sensitivity Check Results + +| Mutation | Location | Tests Failed | Detected? | +|----------|----------|-------------|-----------| +| Summer multiplier 1.2 → 1.25 | line 79 | 8 (all summer pricing + snapshots) | Yes | +| Seat filter `> 0` → `> 1` | line 53 | 1 (1-seat boundary test) | Yes | +| Error message text changed | line 49 | 4 (all validation tests) | Yes | + +## Baseline Snapshot + +``` +Test Suites: 1 passed, 1 total +Tests: 23 passed, 23 total +Snapshots: 0 total +Time: ~1.5s +``` + +## Refactor Gate Recommendation + +**All 23 tests should be mandatory in CI before any refactor lands.** Key groups: + +- **Validation tests (4)**: Protect the API contract for error responses +- **Filtering tests (6)**: Protect search correctness and the zero-seats boundary +- **Pricing tests (8)**: Protect all four dynamic tiers and all seasonal multipliers — the highest-risk area for regressions during pricing logic refactors +- **Snapshot tests (3)**: Catch any unexpected response shape or value drift +- **Rounding test (1)**: Guards against floating-point regressions +- **Shape test (1)**: Guards against field additions/removals in the response diff --git a/session-2/1-characterization-refactoring/__characterization__/flights.characterization.test.ts b/session-2/1-characterization-refactoring/__characterization__/flights.characterization.test.ts new file mode 100644 index 0000000..d6af733 --- /dev/null +++ b/session-2/1-characterization-refactoring/__characterization__/flights.characterization.test.ts @@ -0,0 +1,330 @@ +/** + * Characterization tests for GET /flights endpoint. + * + * These tests lock the current externally observable behavior of the + * /flights search endpoint as a safety net for future refactors. + * Assertions encode *observed* behavior, including quirks. + */ + +import request from 'supertest'; +import { app, server } from '../src/flight-booking-api'; + +afterAll((done) => { + server.close(done); +}); + +beforeEach(async () => { + // Reset all global state to a known baseline before every test + await request(app).post('/reset'); +}); + +// --------------------------------------------------------------------------- +// Helper: bulk-book a flight to reduce its seat count +// --------------------------------------------------------------------------- +async function reduceSeatsByBooking(flightId: string, count: number) { + for (let i = 0; i < count; i++) { + await request(app) + .post('/bookings') + .send({ + flightId, + passengerName: `Seat Filler ${i}`, + passengerEmail: `filler${i}@test.com`, + seatClass: 'economy', + }); + } +} + +// =========================================================================== +// 1. VALIDATION — missing required parameters +// =========================================================================== + +describe('GET /flights — validation', () => { + it('returns 400 when all query params are missing', async () => { + const res = await request(app).get('/flights'); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: 'Missing required parameters: from, to, date', + }); + }); + + it('returns 400 when "from" is missing', async () => { + const res = await request(app).get('/flights?to=NYC&date=2024-06-15'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Missing required parameters: from, to, date'); + }); + + it('returns 400 when "to" is missing', async () => { + const res = await request(app).get('/flights?from=LON&date=2024-06-15'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Missing required parameters: from, to, date'); + }); + + it('returns 400 when "date" is missing', async () => { + const res = await request(app).get('/flights?from=LON&to=NYC'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Missing required parameters: from, to, date'); + }); +}); + +// =========================================================================== +// 2. FILTERING — matching and non-matching criteria +// =========================================================================== + +describe('GET /flights — filtering', () => { + it('returns matching flight for LON→NYC on 2024-06-15', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + expect(res.status).toBe(200); + expect(res.body.flights).toHaveLength(1); + expect(res.body.flights[0].id).toBe('FL001'); + }); + + it('returns empty array when no flights match the route', async () => { + const res = await request(app).get( + '/flights?from=LON&to=TOK&date=2024-06-15' + ); + expect(res.status).toBe(200); + expect(res.body).toEqual({ flights: [] }); + }); + + it('returns empty array when no flights match the date', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-01-01' + ); + expect(res.status).toBe(200); + expect(res.body).toEqual({ flights: [] }); + }); + + it('is case-sensitive — lowercase codes return no results', async () => { + const res = await request(app).get( + '/flights?from=lon&to=nyc&date=2024-06-15' + ); + expect(res.status).toBe(200); + expect(res.body).toEqual({ flights: [] }); + }); + + it('includes flight with exactly 1 seat remaining', async () => { + // FL003: LON→PAR, 2024-06-20, starts with 80 seats — book 79 + await reduceSeatsByBooking('FL003', 79); + + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + expect(res.status).toBe(200); + expect(res.body.flights).toHaveLength(1); + expect(res.body.flights[0].seatsAvailable).toBe(1); + }); + + it('excludes flights with zero seats available', async () => { + // FL003: LON→PAR, 2024-06-20, starts with 80 seats — book all 80 + await reduceSeatsByBooking('FL003', 80); + + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + expect(res.status).toBe(200); + expect(res.body).toEqual({ flights: [] }); + }); +}); + +// =========================================================================== +// 3. RESPONSE SHAPE +// =========================================================================== + +describe('GET /flights — response shape', () => { + it('returns correct fields for each flight result', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + + const flight = res.body.flights[0]; + expect(Object.keys(flight).sort()).toEqual( + ['date', 'from', 'id', 'price', 'seatsAvailable', 'to'].sort() + ); + expect(flight).toMatchObject({ + id: 'FL001', + from: 'LON', + to: 'NYC', + date: '2024-06-15', + seatsAvailable: 150, + }); + expect(typeof flight.price).toBe('number'); + }); +}); + +// =========================================================================== +// 4. PRICING — seasonal adjustments +// =========================================================================== + +describe('GET /flights — seasonal pricing', () => { + // FL001: LON→NYC, 2024-06-15, 150 seats, base $450 + // Dynamic: 150 >= 100 → ×1.0 → $450 + // Seasonal: June → ×1.2 → $540 + it('applies summer multiplier (×1.2) for June flight', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + expect(res.body.flights[0].price).toBe(540); + }); + + // FL005: NYC→TOK, 2024-07-10, 200 seats, base $850 + // Dynamic: 200 >= 100 → ×1.0 → $850 + // Seasonal: July → ×1.2 → $1020 + it('applies summer multiplier (×1.2) for July flight', async () => { + const res = await request(app).get( + '/flights?from=NYC&to=TOK&date=2024-07-10' + ); + expect(res.body.flights[0].price).toBe(1020); + }); + + // FL007: PAR→TOK, 2024-08-05, 120 seats, base $720 + // Dynamic: 120 >= 100 → ×1.0 → $720 + // Seasonal: August → ×1.2 → $864 + it('applies summer multiplier (×1.2) for August flight', async () => { + const res = await request(app).get( + '/flights?from=PAR&to=TOK&date=2024-08-05' + ); + expect(res.body.flights[0].price).toBe(864); + }); + + // FL009: LON→NYC, 2024-12-25, 150 seats, base $450 + // Dynamic: 150 >= 100 → ×1.0 → $450 + // Seasonal: December → ×1.3 → $585 + it('applies winter multiplier (×1.3) for December flight', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-12-25' + ); + expect(res.body.flights[0].price).toBe(585); + }); +}); + +// =========================================================================== +// 5. PRICING — dynamic (seat-availability) tiers +// =========================================================================== + +describe('GET /flights — dynamic pricing tiers', () => { + // FL003: LON→PAR, 2024-06-20, 80 seats, base $120 + // Dynamic: 50 <= 80 < 100 → ×1.1 → $132 + // Seasonal: June → ×1.2 → $158.40 + it('applies ×1.1 multiplier when 50–99 seats remain', async () => { + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + expect(res.body.flights[0].seatsAvailable).toBe(80); + expect(res.body.flights[0].price).toBe(158.4); + }); + + // Reduce FL003 from 80 → 49 seats (book 31) + // Dynamic: 20 <= 49 < 50 → ×1.3 → $156 + // Seasonal: June → ×1.2 → $187.20 + it('applies ×1.3 multiplier when 20–49 seats remain', async () => { + await reduceSeatsByBooking('FL003', 31); + + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + expect(res.body.flights[0].seatsAvailable).toBe(49); + expect(res.body.flights[0].price).toBe(187.2); + }); + + // Reduce FL003 from 80 → 19 seats (book 61) + // Dynamic: 19 < 20 → ×1.5 → $180 + // Seasonal: June → ×1.2 → $216 + it('applies ×1.5 multiplier when fewer than 20 seats remain', async () => { + await reduceSeatsByBooking('FL003', 61); + + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + expect(res.body.flights[0].seatsAvailable).toBe(19); + expect(res.body.flights[0].price).toBe(216); + }); + + // FL001: LON→NYC, 2024-06-15, 150 seats (>= 100) + // Dynamic: ×1.0 → $450 + // Seasonal: June → ×1.2 → $540 + it('applies no dynamic multiplier when >= 100 seats remain', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + expect(res.body.flights[0].seatsAvailable).toBe(150); + expect(res.body.flights[0].price).toBe(540); + }); +}); + +// =========================================================================== +// 6. PRICING — rounding +// =========================================================================== + +describe('GET /flights — price rounding', () => { + it('rounds price to two decimal places', async () => { + // FL003: 120 * 1.1 * 1.2 = 158.4 — already clean + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + const price = res.body.flights[0].price; + // Verify it's a number with at most 2 decimal places + expect(price).toBe(Math.round(price * 100) / 100); + }); +}); + +// =========================================================================== +// 7. SNAPSHOT — full response for regression detection +// =========================================================================== + +describe('GET /flights — snapshot regression', () => { + it('returns full expected response for LON→NYC 2024-06-15', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-06-15' + ); + expect(res.body).toEqual({ + flights: [ + { + id: 'FL001', + from: 'LON', + to: 'NYC', + date: '2024-06-15', + seatsAvailable: 150, + price: 540, + }, + ], + }); + }); + + it('returns full expected response for LON→NYC 2024-12-25 (winter)', async () => { + const res = await request(app).get( + '/flights?from=LON&to=NYC&date=2024-12-25' + ); + expect(res.body).toEqual({ + flights: [ + { + id: 'FL009', + from: 'LON', + to: 'NYC', + date: '2024-12-25', + seatsAvailable: 150, + price: 585, + }, + ], + }); + }); + + it('returns full expected response for LON→PAR 2024-06-20 (80-seat tier)', async () => { + const res = await request(app).get( + '/flights?from=LON&to=PAR&date=2024-06-20' + ); + expect(res.body).toEqual({ + flights: [ + { + id: 'FL003', + from: 'LON', + to: 'PAR', + date: '2024-06-20', + seatsAvailable: 80, + price: 158.4, + }, + ], + }); + }); +}); diff --git a/session-2/1-characterization-refactoring/requests.sh b/session-2/1-characterization-refactoring/requests.sh new file mode 100755 index 0000000..4ee5167 --- /dev/null +++ b/session-2/1-characterization-refactoring/requests.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Flights - GET all flights +curl -s http://localhost:3000/flights + +# Bookings - CREATE a booking +curl -s -X POST http://localhost:3000/bookings \ + -H "Content-Type: application/json" \ + -d '{ + "flightId": "FL001", + "passengerName": "Test Testov", + "passengerEmail": "test@test.com", + "seatClass": "premium" +}' + +# Bookings - GET a booking by ID +curl -s http://localhost:3000/bookings/BK-1773732306909-1 + +# Bookings - UPDATE a booking +curl -s -X PUT http://localhost:3000/bookings/BK-1773732306909-1 \ + -H "Content-Type: application/json" \ + -d '{ + "seatClass": "economy" +}' + +# Bookings - DELETE a booking +curl -s -X DELETE http://localhost:3000/bookings/BK-1773732306909-1 + +# Health check +curl -s http://localhost:3000/health + +# Reset +curl -s -X POST http://localhost:3000/reset diff --git a/session-2/1-characterization-refactoring/src/adapters/in-memory-booking-repository.ts b/session-2/1-characterization-refactoring/src/adapters/in-memory-booking-repository.ts new file mode 100644 index 0000000..d8e36d1 --- /dev/null +++ b/session-2/1-characterization-refactoring/src/adapters/in-memory-booking-repository.ts @@ -0,0 +1,29 @@ +import { Booking } from '../domain/types'; +import { BookingRepository } from '../ports/booking-repository'; + +export class InMemoryBookingRepository implements BookingRepository { + private bookings: Booking[] = []; + private counter = 0; + + nextId(now: Date): string { + this.counter++; + return `BK-${now.getTime()}-${this.counter}`; + } + + save(booking: Booking): void { + this.bookings.push(booking); + } + + findById(id: string): Booking | undefined { + return this.bookings.find((b) => b.id === id); + } + + count(): number { + return this.bookings.length; + } + + reset(): void { + this.bookings = []; + this.counter = 0; + } +} diff --git a/session-2/1-characterization-refactoring/src/adapters/in-memory-flight-repository.ts b/session-2/1-characterization-refactoring/src/adapters/in-memory-flight-repository.ts new file mode 100644 index 0000000..c44e9df --- /dev/null +++ b/session-2/1-characterization-refactoring/src/adapters/in-memory-flight-repository.ts @@ -0,0 +1,48 @@ +import { Flight } from '../domain/types'; +import { FlightRepository } from '../ports/flight-repository'; + +const SEED_FLIGHTS: Flight[] = [ + { id: 'FL001', from: 'LON', to: 'NYC', date: '2024-06-15', seats: 150, base_price: 450 }, + { id: 'FL002', from: 'NYC', to: 'LON', date: '2024-06-15', seats: 150, base_price: 450 }, + { id: 'FL003', from: 'LON', to: 'PAR', date: '2024-06-20', seats: 80, base_price: 120 }, + { id: 'FL004', from: 'PAR', to: 'LON', date: '2024-06-20', seats: 80, base_price: 120 }, + { id: 'FL005', from: 'NYC', to: 'TOK', date: '2024-07-10', seats: 200, base_price: 850 }, + { id: 'FL006', from: 'TOK', to: 'NYC', date: '2024-07-10', seats: 200, base_price: 850 }, + { id: 'FL007', from: 'PAR', to: 'TOK', date: '2024-08-05', seats: 120, base_price: 720 }, + { id: 'FL008', from: 'TOK', to: 'PAR', date: '2024-08-05', seats: 120, base_price: 720 }, + { id: 'FL009', from: 'LON', to: 'NYC', date: '2024-12-25', seats: 150, base_price: 450 }, + { id: 'FL010', from: 'NYC', to: 'LON', date: '2024-12-25', seats: 150, base_price: 450 }, +]; + +export class InMemoryFlightRepository implements FlightRepository { + private flights: Flight[] = []; + + constructor() { + this.reset(); + } + + findAvailable(from: string, to: string, date: string): Flight[] { + return this.flights.filter( + (f) => f.from === from && f.to === to && f.date === date && f.seats > 0 + ); + } + + findById(id: string): Flight | undefined { + return this.flights.find((f) => f.id === id); + } + + updateSeats(flightId: string, delta: number): void { + const flight = this.findById(flightId); + if (flight) { + flight.seats += delta; + } + } + + count(): number { + return this.flights.length; + } + + reset(): void { + this.flights = SEED_FLIGHTS.map((f) => ({ ...f })); + } +} diff --git a/session-2/1-characterization-refactoring/src/domain/cancel-booking.ts b/session-2/1-characterization-refactoring/src/domain/cancel-booking.ts new file mode 100644 index 0000000..d4523dc --- /dev/null +++ b/session-2/1-characterization-refactoring/src/domain/cancel-booking.ts @@ -0,0 +1,40 @@ +import { Booking, Result } from './types'; +import { calculateRefund } from './pricing'; +import { FlightRepository } from '../ports/flight-repository'; +import { BookingRepository } from '../ports/booking-repository'; + +export interface CancelBookingResult { + booking: Booking; + refundAmount: number; +} + +export function cancelBooking( + id: string, + bookingRepo: BookingRepository, + flightRepo: FlightRepository, + now: Date +): Result { + const booking = bookingRepo.findById(id); + if (!booking) { + return { ok: false, error: 'Booking not found', status: 404 }; + } + + if (booking.status === 'cancelled') { + return { ok: false, error: 'Booking already cancelled', status: 400 }; + } + + const refundAmount = calculateRefund( + booking.price, + new Date(booking.bookedAt), + new Date(booking.flight.date), + now + ); + + booking.status = 'cancelled'; + booking.cancelledAt = now.toISOString(); + booking.refundAmount = refundAmount; + + flightRepo.updateSeats(booking.flightId, +1); + + return { ok: true, data: { booking, refundAmount } }; +} diff --git a/session-2/1-characterization-refactoring/src/domain/create-booking.ts b/session-2/1-characterization-refactoring/src/domain/create-booking.ts new file mode 100644 index 0000000..7f28ce9 --- /dev/null +++ b/session-2/1-characterization-refactoring/src/domain/create-booking.ts @@ -0,0 +1,68 @@ +import { Booking, CreateBookingInput, Result } from './types'; +import { calculateBookingPrice, isValidSeatClass } from './pricing'; +import { FlightRepository } from '../ports/flight-repository'; +import { BookingRepository } from '../ports/booking-repository'; + +export function createBooking( + input: CreateBookingInput, + flightRepo: FlightRepository, + bookingRepo: BookingRepository, + now: Date +): Result { + if (!input.flightId) { + return { ok: false, error: 'Flight ID is required', status: 400 }; + } + if (!input.passengerName) { + return { ok: false, error: 'Passenger name is required', status: 400 }; + } + if (!input.passengerEmail) { + return { ok: false, error: 'Passenger email is required', status: 400 }; + } + + const seatClass = input.seatClass || 'economy'; + + const email = input.passengerEmail; + if (!email.includes('@') || !email.includes('.')) { + return { ok: false, error: 'Invalid email format', status: 400 }; + } + + const flight = flightRepo.findById(input.flightId); + if (!flight) { + return { ok: false, error: 'Flight not found', status: 404 }; + } + + if (flight.seats <= 0) { + return { ok: false, error: 'No seats available', status: 400 }; + } + + if (!isValidSeatClass(seatClass)) { + return { ok: false, error: 'Invalid seat class', status: 400 }; + } + + const baggageCount = input.baggage || 0; + const discountCode = input.discountCode || null; + + const price = calculateBookingPrice( + flight.base_price, flight.seats, flight.date, + seatClass, baggageCount, discountCode + ); + + const booking: Booking = { + id: bookingRepo.nextId(now), + flightId: input.flightId, + flight: { from: flight.from, to: flight.to, date: flight.date }, + passengerName: input.passengerName, + passengerEmail: input.passengerEmail, + seatClass, + baggage: baggageCount, + price, + discountCode, + status: 'confirmed', + bookedAt: now.toISOString(), + }; + + bookingRepo.save(booking); + flightRepo.updateSeats(input.flightId, -1); + + return { ok: true, data: booking }; +} diff --git a/session-2/1-characterization-refactoring/src/domain/pricing.ts b/session-2/1-characterization-refactoring/src/domain/pricing.ts new file mode 100644 index 0000000..675d2b2 --- /dev/null +++ b/session-2/1-characterization-refactoring/src/domain/pricing.ts @@ -0,0 +1,85 @@ +export function dynamicPricingMultiplier(seatsRemaining: number): number { + if (seatsRemaining < 20) return 1.5; + if (seatsRemaining < 50) return 1.3; + if (seatsRemaining < 100) return 1.1; + return 1.0; +} + +export function seasonalMultiplier(date: string): number { + const month = new Date(date).getMonth() + 1; + if (month >= 6 && month <= 8) return 1.2; + if (month === 12 || month === 1) return 1.3; + return 1.0; +} + +export function baseFlightPrice( + basePrice: number, + seatsRemaining: number, + date: string +): number { + return basePrice * dynamicPricingMultiplier(seatsRemaining) * seasonalMultiplier(date); +} + +export function calculateFlightPrice( + basePrice: number, + seatsRemaining: number, + date: string +): number { + return Math.round(baseFlightPrice(basePrice, seatsRemaining, date) * 100) / 100; +} + +// Booking-specific pricing + +export const DISCOUNT_CODES: Record = { + 'SUMMER10': 0.10, + 'WINTER20': 0.20, + 'EARLYBIRD': 0.15, + 'STUDENT': 0.25, +}; + +export function isValidSeatClass(seatClass: string): boolean { + return seatClass === 'economy' || seatClass === 'premium' || seatClass === 'business'; +} + +export function seatClassMultiplier(seatClass: string): number { + if (seatClass === 'premium') return 1.5; + if (seatClass === 'business') return 2.5; + return 1.0; +} + +export function calculateBookingPrice( + basePrice: number, + seatsRemaining: number, + date: string, + seatClass: string, + baggageCount: number, + discountCode: string | null +): number { + let price = baseFlightPrice(basePrice, seatsRemaining, date) * seatClassMultiplier(seatClass); + price += baggageCount * 25; + + if (discountCode && DISCOUNT_CODES[discountCode]) { + price *= (1 - DISCOUNT_CODES[discountCode]); + } + + return Math.round(price * 100) / 100; +} + +export function calculateRefund( + bookingPrice: number, + bookedAt: Date, + flightDate: Date, + now: Date +): number { + const hoursSinceBooking = (now.getTime() - bookedAt.getTime()) / (1000 * 60 * 60); + const daysUntilFlight = (flightDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + + let refund = 0; + if (hoursSinceBooking <= 24) { + refund = bookingPrice; + } else if (daysUntilFlight > 7) { + refund = bookingPrice * 0.8; + } + + return Math.round(refund * 100) / 100; +} diff --git a/session-2/1-characterization-refactoring/src/domain/search-flights.ts b/session-2/1-characterization-refactoring/src/domain/search-flights.ts new file mode 100644 index 0000000..db52c36 --- /dev/null +++ b/session-2/1-characterization-refactoring/src/domain/search-flights.ts @@ -0,0 +1,21 @@ +import { FlightSearchResult } from './types'; +import { calculateFlightPrice } from './pricing'; +import { FlightRepository } from '../ports/flight-repository'; + +export function searchFlights( + from: string, + to: string, + date: string, + repository: FlightRepository +): FlightSearchResult[] { + const available = repository.findAvailable(from, to, date); + + return available.map((f) => ({ + id: f.id, + from: f.from, + to: f.to, + date: f.date, + seatsAvailable: f.seats, + price: calculateFlightPrice(f.base_price, f.seats, f.date), + })); +} diff --git a/session-2/1-characterization-refactoring/src/domain/types.ts b/session-2/1-characterization-refactoring/src/domain/types.ts new file mode 100644 index 0000000..0ee36b8 --- /dev/null +++ b/session-2/1-characterization-refactoring/src/domain/types.ts @@ -0,0 +1,52 @@ +export interface Flight { + id: string; + from: string; + to: string; + date: string; + seats: number; + base_price: number; +} + +export interface FlightSearchResult { + id: string; + from: string; + to: string; + date: string; + seatsAvailable: number; + price: number; +} + +export interface Booking { + id: string; + flightId: string; + flight: { from: string; to: string; date: string }; + passengerName: string; + passengerEmail: string; + seatClass: string; + baggage: number; + price: number; + discountCode: string | null; + status: string; + bookedAt: string; + cancelledAt?: string; + refundAmount?: number; + updatedAt?: string; +} + +export interface CreateBookingInput { + flightId: string; + passengerName: string; + passengerEmail: string; + seatClass?: string; + baggage?: number; + discountCode?: string; +} + +export interface UpdateBookingInput { + seatClass?: string; + baggage?: number; +} + +export type Result = + | { ok: true; data: T } + | { ok: false; error: string; status: number }; diff --git a/session-2/1-characterization-refactoring/src/domain/update-booking.ts b/session-2/1-characterization-refactoring/src/domain/update-booking.ts new file mode 100644 index 0000000..447c9bf --- /dev/null +++ b/session-2/1-characterization-refactoring/src/domain/update-booking.ts @@ -0,0 +1,57 @@ +import { Booking, UpdateBookingInput, Result } from './types'; +import { baseFlightPrice, seatClassMultiplier, isValidSeatClass } from './pricing'; +import { FlightRepository } from '../ports/flight-repository'; +import { BookingRepository } from '../ports/booking-repository'; + +export function updateBooking( + id: string, + changes: UpdateBookingInput, + bookingRepo: BookingRepository, + flightRepo: FlightRepository, + now: Date +): Result { + const booking = bookingRepo.findById(id); + if (!booking) { + return { ok: false, error: 'Booking not found', status: 404 }; + } + + if (booking.status === 'cancelled') { + return { ok: false, error: 'Cannot modify cancelled booking', status: 400 }; + } + + let priceChanged = false; + let newPrice = booking.price; + + if (changes.seatClass && changes.seatClass !== booking.seatClass) { + if (!isValidSeatClass(changes.seatClass)) { + return { ok: false, error: 'Invalid seat class', status: 400 }; + } + + const flight = flightRepo.findById(booking.flightId); + if (!flight) { + return { ok: false, error: 'Flight not found', status: 500 }; + } + + const baseDynSeas = baseFlightPrice(flight.base_price, flight.seats, flight.date); + const oldSeatPrice = baseDynSeas * seatClassMultiplier(booking.seatClass); + const newSeatPrice = baseDynSeas * seatClassMultiplier(changes.seatClass); + newPrice += (newSeatPrice - oldSeatPrice); + + booking.seatClass = changes.seatClass; + priceChanged = true; + } + + if (changes.baggage !== undefined && changes.baggage !== booking.baggage) { + const baggageDiff = changes.baggage - booking.baggage; + newPrice += baggageDiff * 25; + booking.baggage = changes.baggage; + priceChanged = true; + } + + if (priceChanged) { + booking.price = Math.round(newPrice * 100) / 100; + booking.updatedAt = now.toISOString(); + } + + return { ok: true, data: booking }; +} diff --git a/session-2/1-characterization-refactoring/src/flight-booking-api.ts b/session-2/1-characterization-refactoring/src/flight-booking-api.ts index 334a0ed..4e5d22d 100644 --- a/session-2/1-characterization-refactoring/src/flight-booking-api.ts +++ b/session-2/1-characterization-refactoring/src/flight-booking-api.ts @@ -1,45 +1,19 @@ -// Legacy Flight Booking API - Single File Implementation -// WARNING: This code is intentionally messy for training purposes -// DO NOT use as example of good code! - import express from 'express'; import bodyParser from 'body-parser'; +import { InMemoryFlightRepository } from './adapters/in-memory-flight-repository'; +import { InMemoryBookingRepository } from './adapters/in-memory-booking-repository'; +import { searchFlights } from './domain/search-flights'; +import { createBooking } from './domain/create-booking'; +import { cancelBooking } from './domain/cancel-booking'; +import { updateBooking } from './domain/update-booking'; const app = express(); app.use(bodyParser.json()); -// Global state - everything stored in memory -let flights: any[] = []; -let bookings: any[] = []; -let booking_counter = 0; - -// Initialize flight data -function init_flights() { - flights = [ - { id: 'FL001', from: 'LON', to: 'NYC', date: '2024-06-15', seats: 150, base_price: 450 }, - { id: 'FL002', from: 'NYC', to: 'LON', date: '2024-06-15', seats: 150, base_price: 450 }, - { id: 'FL003', from: 'LON', to: 'PAR', date: '2024-06-20', seats: 80, base_price: 120 }, - { id: 'FL004', from: 'PAR', to: 'LON', date: '2024-06-20', seats: 80, base_price: 120 }, - { id: 'FL005', from: 'NYC', to: 'TOK', date: '2024-07-10', seats: 200, base_price: 850 }, - { id: 'FL006', from: 'TOK', to: 'NYC', date: '2024-07-10', seats: 200, base_price: 850 }, - { id: 'FL007', from: 'PAR', to: 'TOK', date: '2024-08-05', seats: 120, base_price: 720 }, - { id: 'FL008', from: 'TOK', to: 'PAR', date: '2024-08-05', seats: 120, base_price: 720 }, - { id: 'FL009', from: 'LON', to: 'NYC', date: '2024-12-25', seats: 150, base_price: 450 }, - { id: 'FL010', from: 'NYC', to: 'LON', date: '2024-12-25', seats: 150, base_price: 450 } - ]; -} - -init_flights(); +const flightRepository = new InMemoryFlightRepository(); +const bookingRepository = new InMemoryBookingRepository(); -// Discount codes -const DISCOUNT_CODES: any = { - 'SUMMER10': 0.10, - 'WINTER20': 0.20, - 'EARLYBIRD': 0.15, - 'STUDENT': 0.25 -}; - -// Search flights endpoint +// Search flights app.get('/flights', (req: any, res: any) => { const from = req.query.from; const to = req.query.to; @@ -49,368 +23,61 @@ app.get('/flights', (req: any, res: any) => { return res.status(400).json({ error: 'Missing required parameters: from, to, date' }); } - const available_flights = flights.filter((f: any) => { - if (f.from === from && f.to === to && f.date === date && f.seats > 0) { - return true; - } - return false; - }); - - // Calculate prices for each flight - const flights_with_prices = available_flights.map((f: any) => { - const base = f.base_price; - let price = base; - - // Dynamic pricing based on availability - const seats_left = f.seats; - if (seats_left < 20) { - price = price * 1.5; - } else if (seats_left < 50) { - price = price * 1.3; - } else if (seats_left < 100) { - price = price * 1.1; - } - - // Seasonal adjustment - const flight_date = new Date(f.date); - const month = flight_date.getMonth() + 1; - if (month >= 6 && month <= 8) { - // Summer months - price = price * 1.2; - } else if (month === 12 || month === 1) { - // Winter holidays - price = price * 1.3; - } - - return { - id: f.id, - from: f.from, - to: f.to, - date: f.date, - seatsAvailable: f.seats, - price: Math.round(price * 100) / 100 - }; - }); - - res.json({ flights: flights_with_prices }); + const results = searchFlights(from, to, date, flightRepository); + res.json({ flights: results }); }); -// Create booking endpoint +// Create booking app.post('/bookings', (req: any, res: any) => { - const body = req.body; - - // Validation - if (!body.flightId) { - return res.status(400).json({ error: 'Flight ID is required' }); + const result: any = createBooking(req.body, flightRepository, bookingRepository, new Date()); + if (!result.ok) { + return res.status(result.status).json({ error: result.error }); } - if (!body.passengerName) { - return res.status(400).json({ error: 'Passenger name is required' }); - } - if (!body.passengerEmail) { - return res.status(400).json({ error: 'Passenger email is required' }); - } - if (!body.seatClass) { - body.seatClass = 'economy'; - } - - // Check email format - const email = body.passengerEmail; - if (!email.includes('@') || !email.includes('.')) { - return res.status(400).json({ error: 'Invalid email format' }); - } - - // Find flight - const flight = flights.find((f: any) => f.id === body.flightId); - if (!flight) { - return res.status(404).json({ error: 'Flight not found' }); - } - - // Check availability - if (flight.seats <= 0) { - return res.status(400).json({ error: 'No seats available' }); - } - - // Calculate price - let total_price = 0; - const base_price = flight.base_price; - - // Dynamic pricing - const seats_left = flight.seats; - let dynamic_multiplier = 1.0; - if (seats_left < 20) { - dynamic_multiplier = 1.5; - } else if (seats_left < 50) { - dynamic_multiplier = 1.3; - } else if (seats_left < 100) { - dynamic_multiplier = 1.1; - } - - let price_after_dynamic = base_price * dynamic_multiplier; - - // Seasonal adjustment - const flight_date = new Date(flight.date); - const month = flight_date.getMonth() + 1; - let seasonal_multiplier = 1.0; - if (month >= 6 && month <= 8) { - seasonal_multiplier = 1.2; - } else if (month === 12 || month === 1) { - seasonal_multiplier = 1.3; - } - - price_after_dynamic = price_after_dynamic * seasonal_multiplier; - - // Seat class multiplier - let seat_multiplier = 1.0; - const seatClass = body.seatClass; - if (seatClass === 'premium') { - seat_multiplier = 1.5; - } else if (seatClass === 'business') { - seat_multiplier = 2.5; - } else if (seatClass === 'economy') { - seat_multiplier = 1.0; - } else { - return res.status(400).json({ error: 'Invalid seat class' }); - } - - let price_after_seat = price_after_dynamic * seat_multiplier; - - // Baggage fees - const baggage_count = body.baggage || 0; - const baggage_fee = baggage_count * 25; - - total_price = price_after_seat + baggage_fee; - - // Apply discount code - if (body.discountCode) { - const code = body.discountCode; - if (DISCOUNT_CODES[code]) { - const discount_percent = DISCOUNT_CODES[code]; - total_price = total_price * (1 - discount_percent); - } - } - - total_price = Math.round(total_price * 100) / 100; - - // Create booking - booking_counter++; - const booking_id = 'BK-' + Date.now() + '-' + booking_counter; - - const new_booking: any = { - id: booking_id, - flightId: body.flightId, - flight: { - from: flight.from, - to: flight.to, - date: flight.date - }, - passengerName: body.passengerName, - passengerEmail: body.passengerEmail, - seatClass: seatClass, - baggage: baggage_count, - price: total_price, - discountCode: body.discountCode || null, - status: 'confirmed', - bookedAt: new Date().toISOString() - }; - - bookings.push(new_booking); - - // Update flight seats - flight.seats = flight.seats - 1; - - res.status(201).json(new_booking); + res.status(201).json(result.data); }); -// Get booking endpoint +// Get booking app.get('/bookings/:id', (req: any, res: any) => { - const id = req.params.id; - - const booking = bookings.find((b: any) => b.id === id); - + const booking = bookingRepository.findById(req.params.id); if (!booking) { return res.status(404).json({ error: 'Booking not found' }); } - res.json(booking); }); -// Cancel booking endpoint +// Cancel booking app.delete('/bookings/:id', (req: any, res: any) => { - const id = req.params.id; - - const booking_index = bookings.findIndex((b: any) => b.id === id); - - if (booking_index === -1) { - return res.status(404).json({ error: 'Booking not found' }); - } - - const booking = bookings[booking_index]; - - if (booking.status === 'cancelled') { - return res.status(400).json({ error: 'Booking already cancelled' }); - } - - // Calculate refund - const booked_at = new Date(booking.bookedAt); - const flight_date = new Date(booking.flight.date); - const now = new Date(); - - let refund_amount = 0; - const hours_since_booking = (now.getTime() - booked_at.getTime()) / (1000 * 60 * 60); - const days_until_flight = (flight_date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); - - // Refund policy - if (hours_since_booking <= 24) { - // Full refund if cancelled within 24 hours of booking - refund_amount = booking.price; - } else if (days_until_flight > 7) { - // 80% refund if cancelled more than 7 days before flight - refund_amount = booking.price * 0.8; - } else { - // No refund if cancelled less than 7 days before flight - refund_amount = 0; - } - - refund_amount = Math.round(refund_amount * 100) / 100; - - // Update booking status - booking.status = 'cancelled'; - booking.cancelledAt = now.toISOString(); - booking.refundAmount = refund_amount; - - // Restore flight seat - const flight = flights.find((f: any) => f.id === booking.flightId); - if (flight) { - flight.seats = flight.seats + 1; + const result: any = cancelBooking(req.params.id, bookingRepository, flightRepository, new Date()); + if (!result.ok) { + return res.status(result.status).json({ error: result.error }); } - res.json({ message: 'Booking cancelled', - refundAmount: refund_amount, - booking: booking + refundAmount: result.data.refundAmount, + booking: result.data.booking, }); }); -// Update booking endpoint +// Update booking app.put('/bookings/:id', (req: any, res: any) => { - const id = req.params.id; - const body = req.body; - - const booking = bookings.find((b: any) => b.id === id); - - if (!booking) { - return res.status(404).json({ error: 'Booking not found' }); - } - - if (booking.status === 'cancelled') { - return res.status(400).json({ error: 'Cannot modify cancelled booking' }); - } - - // Check what can be updated - let price_changed = false; - let old_price = booking.price; - let new_price = old_price; - - // Update seat class - if (body.seatClass && body.seatClass !== booking.seatClass) { - const old_class = booking.seatClass; - const new_class = body.seatClass; - - if (new_class !== 'economy' && new_class !== 'premium' && new_class !== 'business') { - return res.status(400).json({ error: 'Invalid seat class' }); - } - - // Recalculate price difference - const flight = flights.find((f: any) => f.id === booking.flightId); - if (!flight) { - return res.status(500).json({ error: 'Flight not found' }); - } - - const base_price = flight.base_price; - - // Apply all the same pricing logic as booking creation - const seats_left = flight.seats; - let dynamic_multiplier = 1.0; - if (seats_left < 20) { - dynamic_multiplier = 1.5; - } else if (seats_left < 50) { - dynamic_multiplier = 1.3; - } else if (seats_left < 100) { - dynamic_multiplier = 1.1; - } - - let price_with_dynamic = base_price * dynamic_multiplier; - - const flight_date = new Date(flight.date); - const month = flight_date.getMonth() + 1; - let seasonal_multiplier = 1.0; - if (month >= 6 && month <= 8) { - seasonal_multiplier = 1.2; - } else if (month === 12 || month === 1) { - seasonal_multiplier = 1.3; - } - - price_with_dynamic = price_with_dynamic * seasonal_multiplier; - - // Calculate old class price - let old_seat_multiplier = 1.0; - if (old_class === 'premium') { - old_seat_multiplier = 1.5; - } else if (old_class === 'business') { - old_seat_multiplier = 2.5; - } - - const old_seat_price = price_with_dynamic * old_seat_multiplier; - - // Calculate new class price - let new_seat_multiplier = 1.0; - if (new_class === 'premium') { - new_seat_multiplier = 1.5; - } else if (new_class === 'business') { - new_seat_multiplier = 2.5; - } - - const new_seat_price = price_with_dynamic * new_seat_multiplier; - - const price_diff = new_seat_price - old_seat_price; - new_price = old_price + price_diff; - - booking.seatClass = new_class; - price_changed = true; + const result: any = updateBooking( + req.params.id, req.body, bookingRepository, flightRepository, new Date() + ); + if (!result.ok) { + return res.status(result.status).json({ error: result.error }); } - - // Update baggage - if (body.baggage !== undefined && body.baggage !== booking.baggage) { - const old_baggage = booking.baggage; - const new_baggage = body.baggage; - - const baggage_diff = new_baggage - old_baggage; - const baggage_fee_diff = baggage_diff * 25; - - new_price = new_price + baggage_fee_diff; - booking.baggage = new_baggage; - price_changed = true; - } - - if (price_changed) { - new_price = Math.round(new_price * 100) / 100; - booking.price = new_price; - booking.updatedAt = new Date().toISOString(); - } - - res.json(booking); + res.json(result.data); }); -// Health check endpoint +// Health check app.get('/health', (req: any, res: any) => { - res.json({ status: 'ok', flights: flights.length, bookings: bookings.length }); + res.json({ status: 'ok', flights: flightRepository.count(), bookings: bookingRepository.count() }); }); -// Reset endpoint (for testing) +// Reset (for testing) app.post('/reset', (req: any, res: any) => { - bookings = []; - booking_counter = 0; - init_flights(); + bookingRepository.reset(); + flightRepository.reset(); res.json({ message: 'System reset' }); }); diff --git a/session-2/1-characterization-refactoring/src/ports/booking-repository.ts b/session-2/1-characterization-refactoring/src/ports/booking-repository.ts new file mode 100644 index 0000000..969e01e --- /dev/null +++ b/session-2/1-characterization-refactoring/src/ports/booking-repository.ts @@ -0,0 +1,9 @@ +import { Booking } from '../domain/types'; + +export interface BookingRepository { + nextId(now: Date): string; + save(booking: Booking): void; + findById(id: string): Booking | undefined; + count(): number; + reset(): void; +} diff --git a/session-2/1-characterization-refactoring/src/ports/flight-repository.ts b/session-2/1-characterization-refactoring/src/ports/flight-repository.ts new file mode 100644 index 0000000..a9612d7 --- /dev/null +++ b/session-2/1-characterization-refactoring/src/ports/flight-repository.ts @@ -0,0 +1,9 @@ +import { Flight } from '../domain/types'; + +export interface FlightRepository { + findAvailable(from: string, to: string, date: string): Flight[]; + findById(id: string): Flight | undefined; + updateSeats(flightId: string, delta: number): void; + count(): number; + reset(): void; +}