Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/api/src/planned/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
2 changes: 2 additions & 0 deletions apps/api/src/planned/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [],
Expand All @@ -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,
Expand Down
45 changes: 42 additions & 3 deletions apps/api/src/planned/test-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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
Expand Down Expand Up @@ -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<typeof users.$inferInsert> = {}) {
const { token, uid } = verifier.issue();
Expand Down Expand Up @@ -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 };
}
68 changes: 67 additions & 1 deletion apps/api/src/routes/planned.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown> }[] };
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();
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/go/testPlanned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/schemas/planned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof PlannedRoutePlace>;
Expand Down
Loading