diff --git a/README.md b/README.md index 77a7445..f8ed5ce 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ un código estable que los clientes HTTP pueden comparar sin leer el mensaje: | `GET /api/courts` | Lista las canchas | `200 { courts: [{ id, name, sport }] }` | | `GET /api/courts/:id/availability?date=YYYY-MM-DD` | Horas libres y ocupadas de una cancha ese día | `200 { courtId, date, slots: [{ hour, available, teamName? }] }` | | `GET /api/members` | Lista a los vecinos registrados | `200 { members: [{ id, name, phone, plan }] }` | -| `GET /api/members/:id/bookings` | Las reservas de un vecino, también las canceladas | `200 [booking, ...]` | +| `GET /api/members/:id/bookings` | **Deprecada, deja de responder el 1 de enero de 2028.** Usen la de abajo | `200 [booking, ...]` | +| `GET /api/v2/members/:id/bookings` | Las reservas de un vecino, también las canceladas | `200 { bookings: [booking, ...] }` | | `POST /api/bookings` | Reserva | `200 { booking }` | | `POST /api/bookings/:id/cancel` | Cancela una reserva | `200 { booking }` | | `GET /api/reports/daily?date=YYYY-MM-DD` | La caja del día, para la administración: reservas, horas y dinero por cancha | `200 { date, bookings, hours, revenueCents, courts: [...] }` | diff --git a/src/http/handlers.ts b/src/http/handlers.ts index 4f1354a..ec3043e 100644 --- a/src/http/handlers.ts +++ b/src/http/handlers.ts @@ -143,12 +143,30 @@ export function cancelBooking(deps: AppDependencies): Handler { }; } -// GET /api/members/:id/bookings +// La v1 sigue respondiendo igual: solo se anuncia su fin (RFC 9745 y RFC 8594). +export const MEMBER_BOOKINGS_V1_DEPRECATED_AT = 1798761600; // 1 de enero de 2027, 00:00 UTC +export const MEMBER_BOOKINGS_V1_SUNSET = 'Sat, 01 Jan 2028 00:00:00 GMT'; + +function bookingsOfMember(deps: AppDependencies, id: string): Booking[] { + const member = deps.service.findMember(Number(id)); + if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${id}.`); + return deps.service.bookingsForMember(member.id).map(toBookingJson); +} + +// GET /api/members/:id/bookings: arreglo suelto, deprecado. export function memberBookings(deps: AppDependencies): Handler { return ({ res, params }) => { - const member = deps.service.findMember(Number(params.id)); - if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${params.id}.`); - sendJson(res, 200, deps.service.bookingsForMember(member.id).map(toBookingJson)); + res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`); + res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET); + res.setHeader('Link', `; rel="successor-version"`); + sendJson(res, 200, bookingsOfMember(deps, params.id)); + }; +} + +// GET /api/v2/members/:id/bookings: un objeto, que puede crecer. +export function memberBookingsV2(deps: AppDependencies): Handler { + return ({ res, params }) => { + sendJson(res, 200, { bookings: bookingsOfMember(deps, params.id) }); }; } diff --git a/src/http/server.ts b/src/http/server.ts index 251064b..ed30624 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -11,6 +11,7 @@ import { listCourts, listMembers, memberBookings, + memberBookingsV2, type AppDependencies, } from './handlers.ts'; import { HttpError, sendError } from './json.ts'; @@ -30,6 +31,7 @@ export function buildRouter(deps: AppDependencies): Router { router.get('/api/courts/:id/availability', courtAvailability(deps)); router.get('/api/members', listMembers(deps)); router.get('/api/members/:id/bookings', memberBookings(deps)); + router.get('/api/v2/members/:id/bookings', memberBookingsV2(deps)); router.post('/api/bookings', createBooking(deps)); router.post('/api/bookings/:id/cancel', cancelBooking(deps)); router.get('/api/reports/daily', dailyReport(deps)); @@ -66,6 +68,8 @@ export function createServer(options: ServerOptions): http.Server { } } finally { log(`${req.method} ${req.url} ${res.statusCode}`); + // Para saber quién sigue usando lo deprecado. Sin el número de vecino: no hace falta. + if (res.hasHeader('Deprecation')) log(`DEPRECATED /api/members/:id/bookings ua="${req.headers['user-agent'] ?? '?'}"`); } }); } diff --git a/test/v2.test.ts b/test/v2.test.ts new file mode 100644 index 0000000..67946b3 --- /dev/null +++ b/test/v2.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, test } from 'node:test'; +import { postJson, startTestServer, type TestServer } from './helpers.ts'; + +// El reloj de las pruebas marca el lunes 9 de marzo de 2026 a las 10:00. +describe('las reservas de un vecino, en v1 y en v2', () => { + let server: TestServer; + + before(async () => { + server = await startTestServer(); + await postJson(`${server.url}/api/bookings`, { courtId: 1, memberId: 2, date: '2026-03-11', startHour: 10, teamName: 'Real Barranco' }); + }); + + after(async () => { + await server.close(); + }); + + test('v1 responde lo mismo que antes y anuncia su fin', async () => { + const response = await fetch(`${server.url}/api/members/2/bookings`); + const body = await response.json(); + assert.equal(response.status, 200); + assert.ok(Array.isArray(body)); + assert.equal(response.headers.get('deprecation'), '@1798761600'); + assert.equal(response.headers.get('sunset'), 'Sat, 01 Jan 2028 00:00:00 GMT'); + assert.equal(response.headers.get('link'), '; rel="successor-version"'); + }); + + test('v2 devuelve las mismas reservas dentro de un objeto y sin aviso', async () => { + const v1 = await (await fetch(`${server.url}/api/members/2/bookings`)).json(); + const response = await fetch(`${server.url}/api/v2/members/2/bookings`); + assert.deepEqual(await response.json(), { bookings: v1 }); + assert.equal(response.headers.get('deprecation'), null); + }); + + test('los errores no cambian entre versiones', async () => { + const response = await fetch(`${server.url}/api/v2/members/99/bookings`); + assert.equal(response.status, 404); + assert.equal(((await response.json()) as { error: { code: string } }).error.code, 'MEMBER_NOT_FOUND'); + }); +});