mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
c956fe92ba
dev now runs on MikroORM, so the positional migrations, seeds.ts and DatabaseService the Tours server side was built on are gone. Behaviour is unchanged; persistence moves onto dev's architecture: - one MikroORM migration for tour_types, tours and tour_waypoints, idempotent so instances that ran the old positional steps keep their data; the hike tour type is seeded inside it, the addon row in the AddonSeeder - entities and repositories for the three tables, tours domain on repositories and UnitOfWork - tour facet joins in places, assignments and days re-applied on the repository code Fixes found while porting: - GPX import checks addon, trip access and permission in the handler instead of guards, so a refused upload gets a 403/404 instead of a connection reset - copying a trip copies its tours and waypoints - a tour cannot be moved onto a day that already has it - updateTour checks existence inside its transaction Adds tours e2e tests and rewrites the tours unit tests onto the migrated test database.
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import { formatAssignmentWithPlace, ratingAggregate } from '../../../../src/nest/common/rowShape';
|
|
import type { AssignmentWithPlaceRow } from '../../../../src/db/repositories/DayAssignments.repository';
|
|
import type { Tag, Participant } from '../../../../src/types';
|
|
|
|
/**
|
|
* Plan 3c Task 2 review (task-2-review.md, "For Task 3" §6.3): `formatAssignmentWithPlace`
|
|
* is typed on `DayAssignmentsRepository`'s own `AssignmentWithPlaceRow` (the
|
|
* honestly-nullable projection row, rule 16) rather than the legacy,
|
|
* narrower `types.ts#AssignmentRow` — this fixture now builds that shape
|
|
* directly, every field required, none defaulted away by a bare `as` cast.
|
|
*/
|
|
function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWithPlaceRow {
|
|
return {
|
|
id: 1,
|
|
day_id: 10,
|
|
place_id: 100,
|
|
order_index: 0,
|
|
notes: 'assignment note',
|
|
reservation_status: 'none',
|
|
reservation_notes: null,
|
|
reservation_datetime: null,
|
|
assignment_time: null,
|
|
assignment_end_time: null,
|
|
end_day: 0,
|
|
leg_transport_mode: null,
|
|
incoming_leg_transport_mode: null,
|
|
accommodation_id: null,
|
|
route_excluded: 0,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
place_name: 'Eiffel Tower',
|
|
place_description: 'Famous landmark',
|
|
lat: 48.8584,
|
|
lng: 2.2945,
|
|
address: 'Champ de Mars, Paris',
|
|
category_id: 5,
|
|
category_name: 'Sightseeing',
|
|
category_color: '#3b82f6',
|
|
category_icon: 'landmark',
|
|
price: 25.0,
|
|
place_currency: 'EUR',
|
|
place_time: '10:00',
|
|
end_time: '12:00',
|
|
duration_minutes: 120,
|
|
place_notes: 'Bring tickets',
|
|
image_url: 'https://example.com/img.jpg',
|
|
transport_mode: 'walk',
|
|
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
|
|
google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0',
|
|
osm_id: null,
|
|
amap_poi_id: null,
|
|
website: 'https://eiffel-tower.com',
|
|
phone: '+33 1 2345 6789',
|
|
stop_type: null,
|
|
fill_percent: null,
|
|
tour_place_id: null,
|
|
tour_route_geometry: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const sampleTags: Partial<Tag>[] = [
|
|
{ id: 1, name: 'Must-see', color: '#ef4444' },
|
|
];
|
|
|
|
const sampleParticipants: Participant[] = [
|
|
{ user_id: 42, username: 'alice', avatar: null },
|
|
];
|
|
|
|
describe('formatAssignmentWithPlace', () => {
|
|
it('nests place fields correctly from flat row', () => {
|
|
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
|
const { place } = result;
|
|
expect(place.id).toBe(100);
|
|
expect(place.name).toBe('Eiffel Tower');
|
|
expect(place.description).toBe('Famous landmark');
|
|
expect(place.lat).toBe(48.8584);
|
|
expect(place.lng).toBe(2.2945);
|
|
expect(place.address).toBe('Champ de Mars, Paris');
|
|
expect(place.price).toBe(25.0);
|
|
expect(place.currency).toBe('EUR');
|
|
expect(place.place_time).toBe('10:00');
|
|
expect(place.end_time).toBe('12:00');
|
|
expect(place.duration_minutes).toBe(120);
|
|
expect(place.notes).toBe('Bring tickets');
|
|
expect(place.image_url).toBe('https://example.com/img.jpg');
|
|
expect(place.transport_mode).toBe('walk');
|
|
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
|
|
expect(place.google_ftid).toBe('0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0');
|
|
expect(place.website).toBe('https://eiffel-tower.com');
|
|
expect(place.phone).toBe('+33 1 2345 6789');
|
|
});
|
|
|
|
it('constructs place.category object when category_id is present', () => {
|
|
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
|
expect(result.place.category).toEqual({
|
|
id: 5,
|
|
name: 'Sightseeing',
|
|
color: '#3b82f6',
|
|
icon: 'landmark',
|
|
});
|
|
});
|
|
|
|
it('sets place.category to null when category_id is null', () => {
|
|
const result = formatAssignmentWithPlace(makeRow({ category_id: null }), [], []);
|
|
expect(result.place.category).toBeNull();
|
|
});
|
|
|
|
it('sets place.category to null when category_id is 0 (falsy)', () => {
|
|
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 }), [], []);
|
|
expect(result.place.category).toBeNull();
|
|
});
|
|
|
|
it('keeps the Tour facet fields at the assignment level, null for an ordinary place', () => {
|
|
expect(formatAssignmentWithPlace(makeRow(), [], [])).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
|
|
const geometry = '[[48.1,11.5],[48.2,11.6]]';
|
|
const tour = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_route_geometry: geometry }), [], []);
|
|
expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry });
|
|
});
|
|
|
|
it('passes tags and participants through untouched', () => {
|
|
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
|
expect(result.place.tags).toEqual(sampleTags);
|
|
expect(result.participants).toEqual(sampleParticipants);
|
|
});
|
|
});
|
|
|
|
describe('ratingAggregate', () => {
|
|
const rate = (rating: number, user_id = 1) => ({ user_id, username: 'u', avatar: null, rating });
|
|
|
|
it('averages the ratings and counts them', () => {
|
|
expect(ratingAggregate([rate(5), rate(2, 2)])).toEqual({ rating_avg: 3.5, rating_count: 2 });
|
|
});
|
|
|
|
it('reports a null average for an empty list rather than NaN', () => {
|
|
expect(ratingAggregate([])).toEqual({ rating_avg: null, rating_count: 0 });
|
|
});
|
|
|
|
it('treats a place with no rating rows at all the same as an empty list', () => {
|
|
expect(ratingAggregate(undefined)).toEqual({ rating_avg: null, rating_count: 0 });
|
|
});
|
|
});
|