diff --git a/apps/api/src/planned/snapshot.test.ts b/apps/api/src/planned/snapshot.test.ts index c7cd8180..8d340c8a 100644 --- a/apps/api/src/planned/snapshot.test.ts +++ b/apps/api/src/planned/snapshot.test.ts @@ -71,4 +71,15 @@ describe('timelineOf', () => { expect(timeline.steps[0]?.conflicts).toEqual(['closed_at_arrival']); expect(timeline.steps[1]?.conflicts).toEqual(['closed_that_day']); }); + + it('raises no conflict for an always-open place without hours, even late (D-049)', async () => { + const routeId = await fixtures.copyDemoRoute(); + await fixtures.alwaysOpenFirstStep(routeId); + + const snapshot = PlannedRouteSnapshot.parse(await snapshotOf(app.db, routeId)); + expect(snapshot.steps[0]?.place).toMatchObject({ alwaysOpen: true, hours: [] }); + const timeline = timelineOf(snapshot, new Date('2026-10-24T23:00:00+02:00')); + + expect(timeline.steps[0]?.conflicts).toEqual([]); + }); }); diff --git a/apps/api/src/planned/snapshot.ts b/apps/api/src/planned/snapshot.ts index fb171806..bd81e22b 100644 --- a/apps/api/src/planned/snapshot.ts +++ b/apps/api/src/planned/snapshot.ts @@ -129,6 +129,7 @@ export async function snapshotOf( location: place.location, address: place.address, isIndoor: place.isIndoor, + alwaysOpen: place.alwaysOpen, hours: hours.get(place.id) ?? [], }, highlights: step.highlights ?? [], @@ -146,6 +147,7 @@ export function timelineOf(snapshot: PlannedRouteSnapshot, startsAt: Date): Rout durationMin: step.durationMin, transitionMin: step.transition?.durationMin ?? null, hours: step.place.hours, + alwaysOpen: step.place.alwaysOpen, })), startsAt, snapshot.timezone, diff --git a/apps/api/src/planned/test-fixtures.ts b/apps/api/src/planned/test-fixtures.ts index 9aa28431..66008c15 100644 --- a/apps/api/src/planned/test-fixtures.ts +++ b/apps/api/src/planned/test-fixtures.ts @@ -1,7 +1,7 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, asc, eq, inArray } from 'drizzle-orm'; import type { buildApp } from '../app'; import type { TestTokenVerifier } from '../auth/test-verifier'; -import { media, routeUnlocks, routes, steps, users } from '../db/schema'; +import { media, places, routeUnlocks, routes, steps, users } from '../db/schema'; import { seed, seedId } from '../db/seed'; import { uuidv7 } from '../db/uuid'; @@ -18,6 +18,7 @@ export function plannedFixtures(app: () => App, verifier: TestTokenVerifier) { const routeIds: string[] = []; const uids: string[] = []; const stepIds: string[] = []; + const placeIds: string[] = []; /** * A copy of the demo route, its first step to book (fictitious link); with `stepPhotos`, the @@ -73,6 +74,42 @@ export function plannedFixtures(app: () => App, verifier: TestTokenVerifier) { return id; } + /** + * Moves the first step of the route `routeId` to a fictitious place « Accessible à toute + * heure » without any hours (D-049), far from the demo so that no search finds it; deleted by + * `cleanUp`. Returns the id of that step. + */ + async function alwaysOpenFirstStep(routeId: string) { + const [first] = await app() + .db.select({ id: steps.id, placeId: steps.placeId }) + .from(steps) + .where(eq(steps.routeId, routeId)) + .orderBy(asc(steps.position)) + .limit(1); + if (!first) throw new Error('The copied route has no step'); + const [demoPlace] = await app() + .db.select({ cityId: places.cityId }) + .from(places) + .where(eq(places.id, first.placeId)); + if (!demoPlace) throw new Error('The demo place is missing from the seed'); + const placeId = uuidv7(); + placeIds.push(placeId); + await app() + .db.insert(places) + .values({ + id: placeId, + cityId: demoPlace.cityId, + name: 'Square toujours ouvert', + category: 'park', + location: { lat: -40, lng: -40 }, + address: 'Adresse fictive', + verificationStatus: 'unverified', + alwaysOpen: true, + }); + await app().db.update(steps).set({ placeId }).where(eq(steps.id, first.id)); + return first.id; + } + /** A signed-in account of the given plan, created on its first request. */ async function signIn(values: Partial = {}) { const { token, uid } = verifier.issue(); @@ -103,7 +140,9 @@ export function plannedFixtures(app: () => App, verifier: TestTokenVerifier) { .db.delete(media) .where(and(eq(media.ownerType, 'step'), inArray(media.ownerId, stepIds))); } + // After the routes: their steps refer to these places. + if (placeIds.length > 0) await app().db.delete(places).where(inArray(places.id, placeIds)); } - return { copyDemoRoute, signIn, unlock, cleanUp }; + return { copyDemoRoute, alwaysOpenFirstStep, signIn, unlock, cleanUp }; } diff --git a/apps/api/src/routes/planned.test.ts b/apps/api/src/routes/planned.test.ts index f8c95a56..1da2a3da 100644 --- a/apps/api/src/routes/planned.test.ts +++ b/apps/api/src/routes/planned.test.ts @@ -1,4 +1,4 @@ -import { ApiError, PlannedRoute, PlannedRouteList } from '@widoo/shared'; +import { ApiError, PlannedRoute, PlannedRouteList, RouteTimeline } from '@widoo/shared'; import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; @@ -157,6 +157,72 @@ describe('GET /v1/me/planned and /v1/me/planned/:id', () => { }); }); +describe('the timeline of a planning and an always-open place (D-049)', () => { + /** A copy of the demo route whose first step is « Accessible à toute heure », without hours. */ + async function alwaysOpenRoute() { + const id = await fixtures.copyDemoRoute(); + await fixtures.alwaysOpenFirstStep(id); + return id; + } + + it('raises no hours_unknown for an always-open place in GET /v1/me/planned', async () => { + const headers = await fixtures.signIn(); + const id = await alwaysOpenRoute(); + const created = PlannedRoute.parse( + (await plan(headers, { routeId: id, startsAt: saturdayTen })).json(), + ); + + const list = PlannedRouteList.parse( + (await app.inject({ url: '/v1/me/planned', headers })).json(), + ); + const planned = list.items.find((item) => item.id === created.id); + expect(planned?.route.steps[0]?.place).toMatchObject({ alwaysOpen: true, hours: [] }); + expect(planned?.timeline.steps[0]?.conflicts).toEqual([]); + }); + + it.each([ + ['the demo route', () => fixtures.copyDemoRoute()], + ['a route with an always-open place', alwaysOpenRoute], + ])( + 'gives the same hours and conflicts as GET /v1/routes/:id/timeline, for %s', + async (_, routeOf) => { + const headers = await fixtures.signIn(); + const id = await routeOf(); + const created = PlannedRoute.parse( + (await plan(headers, { routeId: id, startsAt: saturdayTen })).json(), + ); + const route = await app.inject({ + url: `/v1/routes/${id}/timeline?startsAt=${encodeURIComponent(saturdayTen)}`, + }); + + const read = await app.inject({ url: `/v1/me/planned/${created.id}`, headers }); + expect(PlannedRoute.parse(read.json()).timeline).toEqual(RouteTimeline.parse(route.json())); + }, + ); + + it('reads a snapshot stored before alwaysOpen with alwaysOpen: false', async () => { + const headers = await fixtures.signIn(); + const created = PlannedRoute.parse( + (await plan(headers, { routeId, startsAt: saturdayTen })).json(), + ); + const [row] = await app.db + .select({ snapshot: plannedRoutes.routeRevisionSnapshot }) + .from(plannedRoutes) + .where(eq(plannedRoutes.id, created.id)); + const stored = row?.snapshot as { steps: { place: Record }[] }; + for (const step of stored.steps) delete step.place.alwaysOpen; + await app.db + .update(plannedRoutes) + .set({ routeRevisionSnapshot: stored }) + .where(eq(plannedRoutes.id, created.id)); + + const response = await app.inject({ url: `/v1/me/planned/${created.id}`, headers }); + expect(response.statusCode).toBe(200); + const places = PlannedRoute.parse(response.json()).route.steps.map((step) => step.place); + expect(places.map((place) => place.alwaysOpen)).toEqual([false, false, false, false]); + }); +}); + describe('DELETE /v1/me', () => { it('erases the plannings of the account', async () => { const headers = await fixtures.signIn(); diff --git a/apps/mobile/src/go/testPlanned.ts b/apps/mobile/src/go/testPlanned.ts index 4165ef61..35b52d1c 100644 --- a/apps/mobile/src/go/testPlanned.ts +++ b/apps/mobile/src/go/testPlanned.ts @@ -59,6 +59,7 @@ export function plannedFixture( location: { lat: 48.8674 + index * 0.004, lng: 2.3636 + index * 0.004 }, address: '1 rue Fictive, Paris', isIndoor: index === 1, + alwaysOpen: false, hours: [], }, highlights: [], diff --git a/packages/shared/src/schemas/planned.ts b/packages/shared/src/schemas/planned.ts index 16538216..480d8402 100644 --- a/packages/shared/src/schemas/planned.ts +++ b/packages/shared/src/schemas/planned.ts @@ -51,6 +51,8 @@ export const PlannedRoutePlace = z.object({ location: LatLng, address: z.string().min(1), isIndoor: z.boolean(), + /** « Accessible à toute heure » (`places.always_open`): no conflict in the timeline (D-049). */ + alwaysOpen: z.boolean().default(false), hours: z.array(PlaceHours), }); export type PlannedRoutePlace = z.infer;