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; +}