diff --git a/README.md b/README.md index f8ed5ce..b32869f 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ 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` | **Deprecada, deja de responder el 1 de enero de 2028.** Usen la de abajo | `200 [booking, ...]` | +| `GET /api/members/:id/bookings` | Ya no existe. Usen la de abajo | `410 ENDPOINT_REMOVED` | | `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 }` | @@ -135,6 +135,7 @@ Cuerpo de `POST /api/bookings`: | `INVALID_DATE` | 400 | La fecha no tiene el formato `YYYY-MM-DD` | | `COURT_NOT_FOUND`, `MEMBER_NOT_FOUND`, `BOOKING_NOT_FOUND` | 404 | No existe | | `NOT_FOUND` | 404 | La ruta no existe | +| `ENDPOINT_REMOVED` | 410 | La ruta existió y se retiró; el mensaje dice cuál usar | | `METHOD_NOT_ALLOWED` | 405 | La ruta existe, con otro método | | `SLOT_TAKEN` | 409 | La cancha ya está reservada en ese horario | | `ALREADY_CANCELLED` | 409 | La reserva ya estaba cancelada | diff --git a/src/http/handlers.ts b/src/http/handlers.ts index e338202..6996c6f 100644 --- a/src/http/handlers.ts +++ b/src/http/handlers.ts @@ -143,38 +143,17 @@ export function cancelBooking(deps: AppDependencies): Handler { }; } -// 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); } -// Apagones anunciados: durante estas ventanas la v1 ya responde como responderá -// después del fin. Así aparece quien no sabía que dependía de ella. -export const MEMBER_BOOKINGS_V1_BROWNOUTS = [ - { from: '2027-09-01T15:00:00Z', to: '2027-09-01T16:00:00Z' }, - { from: '2027-10-05T15:00:00Z', to: '2027-10-05T21:00:00Z' }, - { from: '2027-11-02T13:00:00Z', to: '2027-11-03T13:00:00Z' }, -]; - -export function isBrownout(instant: Date): boolean { - return MEMBER_BOOKINGS_V1_BROWNOUTS.some(({ from, to }) => instant >= new Date(from) && instant < new Date(to)); -} - -// GET /api/members/:id/bookings: arreglo suelto, deprecado. -export function memberBookings(deps: AppDependencies): Handler { - return ({ res, params }) => { - if (isBrownout(deps.clock.now())) { - throw new HttpError(410, 'ENDPOINT_REMOVED', `Esta ruta deja de existir: usen /api/v2/members/${params.id}/bookings, que devuelve { bookings }.`); - } - 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/members/:id/bookings ya no existe. Se responde 410 por un tiempo, para +// que quien se haya quedado atrás lea qué hacer. +export function memberBookingsRemoved(): Handler { + return ({ params }) => { + throw new HttpError(410, 'ENDPOINT_REMOVED', `Esta ruta ya no existe: usen /api/v2/members/${params.id}/bookings, que devuelve { bookings }.`); }; } diff --git a/src/http/server.ts b/src/http/server.ts index ed30624..b39a724 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -10,7 +10,7 @@ import { dailyReport, listCourts, listMembers, - memberBookings, + memberBookingsRemoved, memberBookingsV2, type AppDependencies, } from './handlers.ts'; @@ -30,7 +30,7 @@ export function buildRouter(deps: AppDependencies): Router { router.get('/api/courts', listCourts(deps)); 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/members/:id/bookings', memberBookingsRemoved()); router.get('/api/v2/members/:id/bookings', memberBookingsV2(deps)); router.post('/api/bookings', createBooking(deps)); router.post('/api/bookings/:id/cancel', cancelBooking(deps)); @@ -68,8 +68,6 @@ 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-migrado.test.ts b/test/v2-migrado.test.ts index 13e6bbd..2fdb7b5 100644 --- a/test/v2-migrado.test.ts +++ b/test/v2-migrado.test.ts @@ -1,26 +1,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; -import { isBrownout } from '../src/http/handlers.ts'; -import { startTestServer } from './helpers.ts'; test('la página ya no usa la ruta deprecada', () => { const page = readFileSync('public/app.js', 'utf8'); assert.doesNotMatch(page, /`\/api\/members\/[^`]*\/bookings`/); assert.match(page, /\/api\/v2\/members\//); }); - -test('durante un apagón la v1 responde 410 con lo que hay que hacer; la v2 sigue', async () => { - const server = await startTestServer({ now: '2027-09-01T15:30:00Z' }); - const old = await fetch(`${server.url}/api/members/2/bookings`); - assert.equal(old.status, 410); - assert.match(((await old.json()) as { error: { message: string } }).error.message, /\/api\/v2\/members\/2\/bookings/); - assert.equal((await fetch(`${server.url}/api/v2/members/2/bookings`)).status, 200); - await server.close(); -}); - -test('un apagón empieza y termina a la hora anunciada', () => { - assert.equal(isBrownout(new Date('2027-09-01T14:59:59Z')), false); - assert.equal(isBrownout(new Date('2027-09-01T15:00:00Z')), true); - assert.equal(isBrownout(new Date('2027-09-01T16:00:00Z')), false); -}); diff --git a/test/v2.test.ts b/test/v2.test.ts index 67946b3..9320e1a 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -3,7 +3,7 @@ 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', () => { +describe('las reservas de un vecino, solo en v2', () => { let server: TestServer; before(async () => { @@ -15,24 +15,20 @@ describe('las reservas de un vecino, en v1 y en v2', () => { await server.close(); }); - test('v1 responde lo mismo que antes y anuncia su fin', async () => { + test('la ruta vieja responde 410 y dice cuál usar', 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"'); + const { error } = (await response.json()) as { error: { code: string; message: string } }; + assert.equal(response.status, 410); + assert.equal(error.code, 'ENDPOINT_REMOVED'); + assert.match(error.message, /\/api\/v2\/members\/2\/bookings/); }); - 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('v2 devuelve un objeto con las reservas', async () => { + const body = (await (await fetch(`${server.url}/api/v2/members/2/bookings`)).json()) as { bookings: { teamName: string }[] }; + assert.deepEqual(body.bookings.map((b) => b.teamName), ['Real Barranco']); }); - test('los errores no cambian entre versiones', async () => { + test('los errores de v2 siguen siendo los mismos', 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');