mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
939452f9d8
* refactor(server): route every trip-access check through DatabaseService
Twelve Nest services carry the same verifyTripAccess/canEdit pair. Six already
asked DatabaseService.canAccessTrip; the other six went through
services/tripAccess, a nine-line re-export of the identical function. Same
query, same result, two ways to reach it.
All twelve now take the injected route, so services/tripAccess is gone.
The bigger duplication was in the types. Thirteen files declared their own
`Trip` for the same return value, in three different shapes:
NonNullable<ReturnType<typeof verifyTripAccess>> 6x
{ user_id: number } 4x
TripAccess 3x
The narrow one is why assignments, days, places and accommodations all wrote
`as Trip | null | undefined` on the call — the cast existed to shrink
TripAccess down to a local type that was never meant to differ from it. All
thirteen now say TripAccess, and the four casts are gone with no other change.
middleware/tripAccess.ts goes too: it was already dead in production — nothing
under src/ imported requireTripAccess or requireTripOwner, only comments in the
services that had absorbed them. Its test went with it.
* refactor(server): fold the query helpers and trip joins into the container
Two more legacy files whose only reason for staying outside the container was
that they predated it.
services/queryHelpers.ts held five exports doing two different jobs. Three are
the batch loaders that keep the list endpoints off N+1 queries — they hit the
database and are now QueryHelpersService over the injected connection, imported
by assignments, days, places and share. The other two, formatAssignmentWithPlace
and ratingAggregate, touch no database at all; they sat next to the loaders only
because the loaders were free functions too. Those stay free functions, in
common/rowShape.ts.
The README note on the assignments service claimed the loaders had to stay in
services/ because the day, share and place services were unmigrated. All three
have since moved, so the reason is gone.
services/tripMembership.ts becomes TripMembershipService in its own module. It
could have folded into auth or trip-invite, but both call it — as do oidc and
the plugin RPC host — and putting it in either would have made AuthModule and
TripInviteModule import each other.
Neither module is @Global, matching PermissionsModule: e2e TestingModules
resolve them transitively through each consumer's explicit import.
Test seams changed shape accordingly. Two path mocks of services/tripMembership
became injected stubs; the trip-invite e2e overrides the provider in the
container instead. The oidc test keeps the real implementation, which is what
OIDC-SVC-045 was always asserting.
ratingAggregate moved out of the ungated services/ tree into the coverage-gated
one, so it gets the direct tests it never had — as does the tags/participants
pass-through, which two long-dead fixture variables in the moved test file had
been waiting for.
Also drops four stale comments that outlived their subject: the requireTripAccess
middleware they pointed at was deleted in the previous commit.
113 lines
4.0 KiB
TypeScript
113 lines
4.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import { formatAssignmentWithPlace, ratingAggregate } from '../../../../src/nest/common/rowShape';
|
|
import type { AssignmentRow, Tag, Participant } from '../../../../src/types';
|
|
|
|
function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
|
|
return {
|
|
id: 1,
|
|
day_id: 10,
|
|
place_id: 100,
|
|
order_index: 0,
|
|
notes: 'assignment note',
|
|
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',
|
|
website: 'https://eiffel-tower.com',
|
|
phone: '+33 1 2345 6789',
|
|
...overrides,
|
|
} as AssignmentRow;
|
|
}
|
|
|
|
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 as any }), [], []);
|
|
expect(result.place.category).toBeNull();
|
|
});
|
|
|
|
it('sets place.category to null when category_id is 0 (falsy)', () => {
|
|
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
|
|
expect(result.place.category).toBeNull();
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|