Files
TREK/client/tests/unit/mobile/trip/filesModel.test.ts
T
Maurice 973773e003 refactor(files): share the file filter, link list, note save and trash between desktop and phone
fileListRules now holds the type category of a file, the filter tab match
and the list of places and bookings a file is linked to (own column plus
extra links, transport or booking). useFileManager and FileManagerRow on
desktop and filesModel on the phone run on it. fileActions gains the field
save with its reload and assign error, which the desktop assign dialog and
the phone note edit both use, and the trash call with its toasts, used by
the desktop delete and the phone menu sheet. The phone sheet no longer
imports filesApi.

Differences that stay, each as an explicit option:
PHONE_FILE_FILTER_RULES sorts pdf, image and doc by type category (videos
count as images, doc is every file that is no PDF and no media, a pass
with a PDF mime type is a doc) and counts a note id of 0 as collab, while
desktop reads the mime type (images only, doc is Word, Excel and text).
DESKTOP_FILE_LINK_LIST_RULES skips an own place or booking column of 0,
the phone only a null one. PHONE_FILE_UPDATE_RULES waits for the reload
after a save, so a failed reload shows the assign error there; desktop
starts it and moves on. DESKTOP_FILE_TRASH_TOAST_RULES falls back to
English when the trash toast translation is empty.
2026-10-09 15:19:51 +02:00

182 lines
8.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { FileText, Image as ImageIcon, Ticket } from 'lucide-react';
import {
FILE_FILTERS,
buildFileLinkLabels,
formatFileDate,
getFileTypeMeta,
matchesFileFilter,
sortFilesStarredFirst,
type FileFilterId,
} from '../../../../src/mobile/screens/trip/tabs/filesModel';
import { getFileTypeCategory } from '../../../../src/components/Files/fileListRules';
import { buildPlace, buildReservation, buildTripFile } from '../../../helpers/factories';
import type { TranslationFn, TripFile } from '../../../../src/types';
// FE-MOB-FILM-001 to FE-MOB-FILM-017
const t: TranslationFn = key => key;
describe('filesModel — type categorisation', () => {
it('FE-MOB-FILM-001: detects wallet passes ahead of everything else', () => {
expect(getFileTypeCategory({ mime_type: 'application/vnd.apple.pkpass', original_name: 'boardingpass' })).toBe('pass');
// extension wins even when the browser sent a generic MIME
expect(getFileTypeCategory({ mime_type: 'application/octet-stream', original_name: 'ticket.pkpass' })).toBe('pass');
});
it('FE-MOB-FILM-002: detects PDFs and media', () => {
expect(getFileTypeCategory({ mime_type: 'application/pdf', original_name: 'invoice.pdf' })).toBe('pdf');
expect(getFileTypeCategory({ mime_type: 'image/jpeg', original_name: 'beach.jpg' })).toBe('image');
// video folds into the image bucket (#823) since both open in the lightbox
expect(getFileTypeCategory({ mime_type: 'video/mp4', original_name: 'clip.mp4' })).toBe('image');
});
it('FE-MOB-FILM-003: detects spreadsheets by extension and by MIME', () => {
expect(getFileTypeCategory({ mime_type: '', original_name: 'budget.xlsx' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: '', original_name: 'list.csv' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: '', original_name: 'sheet.ods' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: 'application/vnd.ms-excel', original_name: 'legacy' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: 'text/csv', original_name: 'export' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: 'application/vnd.oasis.opendocument.spreadsheet', original_name: 'x' })).toBe('xls');
});
it('FE-MOB-FILM-004: falls back to "other" for plain attachments', () => {
expect(getFileTypeCategory({ mime_type: 'application/zip', original_name: 'trip.zip' })).toBe('other');
expect(getFileTypeCategory({ mime_type: null, original_name: null })).toBe('other');
});
it('FE-MOB-FILM-005: getFileTypeMeta pairs the category with its tile icon, colour and caption', () => {
expect(getFileTypeMeta({ mime_type: 'application/pdf', original_name: 'a.pdf' })).toEqual({
category: 'pdf',
icon: FileText,
color: '#D6273B',
label: 'PDF',
});
expect(getFileTypeMeta({ mime_type: 'image/png', original_name: 'a.png' }).icon).toBe(ImageIcon);
expect(getFileTypeMeta({ mime_type: null, original_name: 'a.pkpass' }).icon).toBe(Ticket);
expect(getFileTypeMeta({ mime_type: 'application/zip', original_name: 'a.zip' }).label).toBe('FILE');
});
});
describe('filesModel — filters', () => {
const pdf = buildTripFile({ id: 1, mime_type: 'application/pdf', original_name: 'invoice.pdf' });
const image = buildTripFile({ id: 2, mime_type: 'image/jpeg', original_name: 'beach.jpg' });
const sheet = buildTripFile({ id: 3, mime_type: 'text/csv', original_name: 'budget.csv' });
const pass = buildTripFile({ id: 4, mime_type: 'application/vnd.apple.pkpass', original_name: 'bp.pkpass' });
const zip = buildTripFile({ id: 5, mime_type: 'application/zip', original_name: 'stuff.zip' });
const starred = buildTripFile({ id: 6, mime_type: 'application/pdf', original_name: 's.pdf', starred: 1 });
const collab = buildTripFile({ id: 7, mime_type: 'application/pdf', original_name: 'c.pdf', note_id: 42 });
const all = [pdf, image, sheet, pass, zip, starred, collab];
const idsFor = (filter: FileFilterId) => all.filter(f => matchesFileFilter(f, filter)).map(f => f.id);
it('FE-MOB-FILM-006: the filter grid is defined in display order', () => {
expect(FILE_FILTERS.map(f => f.id)).toEqual(['all', 'pdf', 'image', 'doc', 'starred', 'collab']);
expect(FILE_FILTERS.every(f => f.labelKey.startsWith('files.filter'))).toBe(true);
});
it('FE-MOB-FILM-007: "all" keeps everything, "pdf" and "image" keep their type', () => {
expect(idsFor('all')).toEqual([1, 2, 3, 4, 5, 6, 7]);
expect(idsFor('pdf')).toEqual([1, 6, 7]);
expect(idsFor('image')).toEqual([2]);
});
it('FE-MOB-FILM-008: "doc" is the catch-all for xls, passes and generic files', () => {
expect(idsFor('doc')).toEqual([3, 4, 5]);
});
it('FE-MOB-FILM-009: "starred" and "collab" read their flags', () => {
expect(idsFor('starred')).toEqual([6]);
expect(idsFor('collab')).toEqual([7]);
expect(matchesFileFilter(buildTripFile({ starred: 0 }), 'starred')).toBe(false);
});
it('FE-MOB-FILM-010: the filter tiles fully partition the list', () => {
const covered = new Set([...idsFor('pdf'), ...idsFor('image'), ...idsFor('doc')]);
expect(covered.size).toBe(all.length);
});
it('FE-MOB-FILM-011: an unknown filter id keeps every file', () => {
expect(matchesFileFilter(pdf, 'bogus' as FileFilterId)).toBe(true);
});
});
describe('filesModel — sorting and dates', () => {
it('FE-MOB-FILM-012: sortFilesStarredFirst hoists starred files and keeps the rest stable', () => {
const files = [
buildTripFile({ id: 1 }),
buildTripFile({ id: 2, starred: 1 }),
buildTripFile({ id: 3 }),
buildTripFile({ id: 4, starred: 1 }),
];
const sorted = sortFilesStarredFirst(files);
expect(sorted.map(f => f.id)).toEqual([2, 4, 1, 3]);
expect(files.map(f => f.id)).toEqual([1, 2, 3, 4]);
});
it('FE-MOB-FILM-013: formatFileDate renders a compact day/month label', () => {
expect(formatFileDate('2026-07-16T10:00:00.000Z', 'en-US')).toMatch(/Jul\s16|16\sJul/);
expect(formatFileDate('2026-07-16T10:00:00.000Z', 'de-DE')).toMatch(/16\.\s?Juli/);
});
it('FE-MOB-FILM-014: formatFileDate returns an empty string for missing or broken dates', () => {
expect(formatFileDate(null, 'en-US')).toBe('');
expect(formatFileDate(undefined, 'en-US')).toBe('');
expect(formatFileDate('', 'en-US')).toBe('');
expect(formatFileDate('not-a-date', 'en-US')).toBe('');
});
});
describe('filesModel — link labels', () => {
const places = [buildPlace({ id: 11, name: 'Louvre' }), buildPlace({ id: 12, name: 'Eiffel Tower' })];
const reservations = [
buildReservation({ id: 21, type: 'flight', title: 'LH123' }),
buildReservation({ id: 22, type: 'hotel', title: 'Hotel Ritz' }),
buildReservation({ id: 23, type: 'hotel', title: '' }),
];
const transportTypes = new Set(['flight', 'train']);
function file(overrides: Partial<TripFile>): TripFile {
return buildTripFile({ id: 1, ...overrides });
}
it('FE-MOB-FILM-015: merges the single and multi link columns and skips unknown ids', () => {
const labels = buildFileLinkLabels(
file({ place_id: 11, linked_place_ids: [11, 12, null, 99] }),
places,
reservations,
transportTypes,
t,
);
// 11 appears in both columns but is listed once; 99 does not exist any more
expect(labels).toEqual(['files.sourcePlan · Louvre', 'files.sourcePlan · Eiffel Tower']);
});
it('FE-MOB-FILM-016: splits reservations into transport and booking and appends the collab note', () => {
const labels = buildFileLinkLabels(
file({
reservation_id: 21,
linked_reservation_ids: [22, 23, null, 98],
note_id: 5,
}),
places,
reservations,
transportTypes,
t,
);
expect(labels).toEqual([
'files.sourceTransport · LH123',
'files.sourceBooking · Hotel Ritz',
// an untitled reservation repeats its own source label
'files.sourceBooking · files.sourceBooking',
'files.sourceCollab',
]);
});
it('FE-MOB-FILM-017: an unlinked file has no labels', () => {
expect(buildFileLinkLabels(file({}), places, reservations, transportTypes, t)).toEqual([]);
});
});