test(journey): the journal link repository on its own, without the two reads nothing called

linkRecipientUserIds and listTripIdsForJourney had no caller and kept src/db/repositories under its 99 % statement floor. TJLREPO-001..007 cover the link rows, the members list, the reach across trips ending with the membership, the hide-skeletons choice of a link member, the role precedence and leaveJournalLink.
This commit is contained in:
Maurice
2026-10-18 20:40:46 +02:00
parent 48d573c60d
commit ffa7333bb1
2 changed files with 174 additions and 20 deletions
@@ -213,24 +213,4 @@ export class TripJournalLinksRepository extends TrekRepository<TripJournalLinks>
.where('trip_id', '=', tripId)
.execute();
}
/** Everyone a journal's links let in, for its live updates. */
async linkRecipientUserIds(journeyId: number): Promise<number[]> {
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
.select('m.user_id')
.distinct()
.where('l.journey_id', '=', journeyId)
.execute();
return rows.map((r) => r.user_id);
}
/** The trips that show this journal, for telling their members when it changes hands. */
async listTripIdsForJourney(journeyId: number): Promise<number[]> {
const rows = await this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_links')
.select('trip_id')
.where('journey_id', '=', journeyId)
.execute();
return rows.map((r) => r.trip_id);
}
}
@@ -0,0 +1,174 @@
/**
* `TripJournalLinksRepository` and the link's reach (TJLREPO-*): one journal
* per trip, the members it lets in, and the rule every read of link access
* starts from: a member counts only while they are still on the linking trip.
* Also the roster's side, `TripMembersRepository.leaveJournalLink`.
*/
import { JourneyContributors } from '../../../../src/db/entities/JourneyContributors.entity';
import { TripJournalLinks } from '../../../../src/db/entities/TripJournalLinks.entity';
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
import type { JourneyContributorsRepository } from '../../../../src/db/repositories/JourneyContributors.repository';
import type { TripJournalLinksRepository } from '../../../../src/db/repositories/TripJournalLinks.repository';
import type { TripMembersRepository } from '../../../../src/db/repositories/TripMembers.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { addJourneyContributor, makeJourney } from '../../../helpers/factories/journeys';
import { countRows } from '../../../helpers/factories/rows';
import { addTripMember, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let links: TripJournalLinksRepository;
let contributors: JourneyContributorsRepository;
let roster: TripMembersRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
links = t.repo(TripJournalLinks);
contributors = t.repo(JourneyContributors);
roster = t.repo(TripMembers);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** A trip owned by `owner` with Lena and Tom on it, a guest, and a journal of the owner's. */
async function scene() {
const { user: owner } = await makeUser(t, { username: 'Owner' });
const { user: lena } = await makeUser(t, { username: 'Lena' });
const { user: tom } = await makeUser(t, { username: 'Tom' });
const { user: guest } = await makeUser(t, { username: 'Guest', is_guest: 1 });
const trip = await makeTrip(t, owner.id);
await Promise.all([lena, tom, guest].map((m) => addTripMember(t, trip.id, m.id)));
const journal = await makeJourney(t, owner.id, { title: 'Japan' });
return { owner, lena, tom, guest, trip, journal };
}
describe('TripJournalLinksRepository', () => {
it('TJLREPO-001: a trip without a link reads as none, and its people with the owner first', async () => {
const s = await scene();
expect(await links.findForTrip(s.trip.id)).toBeNull();
expect(await links.listMembers(s.trip.id)).toEqual([]);
const people = await links.listTripPeople(s.trip.id);
expect(people.map((p) => [p.username, p.is_trip_owner, p.is_guest])).toEqual([
['Owner', true, false],
['Guest', false, true],
['Lena', false, false],
['Tom', false, false],
]);
});
it('TJLREPO-002: linking reads back with the journal, and relinking keeps one row per trip', async () => {
const s = await scene();
const other = await makeJourney(t, s.owner.id, { title: 'Iceland' });
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.upsertLink(s.trip.id, other.id, s.owner.id, 2);
const row = await links.findForTrip(s.trip.id);
expect(row).toMatchObject({ trip_id: s.trip.id, journey_id: other.id, journey_title: 'Iceland', created_at: 2 });
expect(await countRows(t, TripJournalLinks, {})).toBe(1);
});
it('TJLREPO-003: members are replaced as a list, and a trip without a link takes none', async () => {
const s = await scene();
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
expect(await links.listMembers(s.trip.id)).toEqual([]);
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.tom.id, role: 'editor' },
{ user_id: s.lena.id, role: 'viewer' },
],
1,
);
expect((await links.listMembers(s.trip.id)).map((m) => [m.username, m.role])).toEqual([
['Lena', 'viewer'],
['Tom', 'editor'],
]);
await links.replaceMembers(s.trip.id, [], 2);
expect(await links.listMembers(s.trip.id)).toEqual([]);
});
it('TJLREPO-004: the reach gives the stronger role across trips and ends with the membership', async () => {
const s = await scene();
const second = await makeTrip(t, s.owner.id);
await addTripMember(t, second.id, s.lena.id);
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.upsertLink(second.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
await links.replaceMembers(second.id, [{ user_id: s.lena.id, role: 'editor' }], 1);
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('editor');
expect(await contributors.findAccessRole(s.journal.id, s.tom.id)).toBeUndefined();
await roster.remove(second.id, s.lena.id);
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('viewer');
await roster.remove(s.trip.id, s.lena.id);
expect(await contributors.hasAccess(s.journal.id, s.lena.id)).toBe(false);
});
it('TJLREPO-005: a link member keeps a hide-skeletons choice of their own, apart from contributors', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
expect(await contributors.getHideSkeletons(s.journal.id, s.lena.id)).toBe(false);
await contributors.setHideSkeletons(s.journal.id, s.lena.id, 1);
expect(await contributors.getHideSkeletons(s.journal.id, s.lena.id)).toBe(true);
expect(await contributors.getHideSkeletons(s.journal.id, s.tom.id)).toBe(false);
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
await contributors.setHideSkeletons(s.journal.id, s.tom.id, 1);
expect(await contributors.getHideSkeletons(s.journal.id, s.tom.id)).toBe(true);
});
it('TJLREPO-006: a direct editor stays one, and a link editor outranks a direct viewer', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.tom.id, role: 'editor' },
],
1,
);
await addJourneyContributor(t, s.journal.id, s.lena.id, 'editor');
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('editor');
expect(await contributors.findAccessRole(s.journal.id, s.tom.id)).toBe('editor');
expect(await contributors.hasAccess(s.journal.id, s.lena.id)).toBe(true);
expect(await contributors.hasAccess(s.journal.id, s.guest.id)).toBe(false);
});
it('TJLREPO-007: unlinking takes the members along, and leaving the trip takes the leaver off', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.tom.id, role: 'viewer' },
],
1,
);
await roster.leaveJournalLink(s.trip.id, s.lena.id);
expect((await links.listMembers(s.trip.id)).map((m) => m.username)).toEqual(['Tom']);
await links.deleteForTrip(s.trip.id);
expect(await links.findForTrip(s.trip.id)).toBeNull();
expect(await links.listMembers(s.trip.id)).toEqual([]);
});
});