Files
SRCmail/lib/__tests__/download-filename.test.ts
T
Stefan HildebrandtandLinus Rath 2fac6ebfb8 test: add characterisation tests for untested integration seams
Golden-master tests pinning the CURRENT behavior of high-value modules
that had no coverage — integration seams, security helpers, two API
route handlers, and complex pure utils. 111 tests across 12 files.

New tests:
- auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip,
  tamper/version/missing-secret handling; cookie-slot naming.
- unified-mailbox: multi-account fan-out, sort, totals, per-account error
  isolation, personal-vs-shared JMAP target resolution, counts/roles.
- account-state-manager: snapshot/restore across the six real Zustand
  stores; clearAllStores reset shape; evict.
- mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers).
- tnef: winmail.dat binary parsing from hand-built fixtures.
- download-filename / subject-prefix / birthday-calendar / eml-import:
  filename templating, multilingual prefix stripping, birthday event
  generation, .eml/.zip import.
- webdav / caldav-discover route handlers: auth guards, path validation,
  upstream URL construction, candidate probing.
- helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient.

Tests follow the repo's existing patterns (route-import, fake IJMAPClient,
fetch spy, real store singletons). Where current behavior looks buggy it
is pinned and flagged with a // CHARACTERISATION: comment (see PR for the
suspected-bugs list); no production code is changed.
2026-06-19 23:52:42 +02:00

128 lines
5.0 KiB
TypeScript

// Pin TZ so the local-time date rendering in dateParts is deterministic.
process.env.TZ = 'UTC';
import { describe, it, expect } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import {
emailExportFilename,
attachmentDownloadFilename,
bundleExportFilename,
emailVars,
attachmentVars,
buildSampleEmail,
} from '@/lib/download-filename';
const makeEmail = (over: Partial<Email>): Email =>
({ id: 'e', receivedAt: '2026-05-22T14:05:33Z', from: [], to: [], subject: '', ...over } as unknown as Email);
describe('emailExportFilename', () => {
it('renders the default template from the sample email (UTC)', () => {
expect(emailExportFilename(buildSampleEmail())).toBe(
'2026-05-22 14.05.33 (Alice Sender-Bob Recipient) Benachrichtigung von Ihrem Gerät.eml',
);
});
it('applies lowercase + stripDiacritics + underscore-spaces transforms', () => {
expect(
emailExportFilename(buildSampleEmail(), {
template: '{from_name}-{subject}',
lowercase: true,
stripDiacritics: true,
spaceReplacement: 'underscore',
}),
).toBe('alice_sender-benachrichtigung_von_ihrem_gerat.eml');
});
it('falls back to "no subject" for an empty subject', () => {
expect(emailExportFilename(makeEmail({ subject: '' }), '{subject}')).toBe('no subject.eml');
});
it('falls back to "email" when the template renders empty', () => {
expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml');
});
it('CHARACTERISATION: per-token sanitise caps each value at 80 chars', () => {
// sanitizePart defaults to maxLen=80, applied per {token} during render —
// so a single long {subject} is truncated to 80 well before the 200 cap.
const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}');
expect(out).toBe('a'.repeat(80) + '.eml');
});
});
describe('attachmentDownloadFilename', () => {
it('email===null: sanitises the raw attachment name, applying transforms', () => {
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' })).toBe('Report.PDF');
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' }, { lowercase: true })).toBe('report.pdf');
});
it('{filename} token preserves the original extension', () => {
expect(
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.pdf' }, '{filename}'),
).toBe('My Report.pdf');
});
it('template without {ext}/{filename} appends the attachment extension', () => {
expect(
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, '{name}'),
).toBe('My Report.PDF');
expect(
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, { template: '{name}', lowercase: true }),
).toBe('my report.pdf');
});
it('attachment with no extension yields no trailing dot', () => {
expect(attachmentDownloadFilename(buildSampleEmail(), { name: 'noext' }, '{name}')).toBe('noext');
});
it('sanitises path-traversal characters out of the name', () => {
const out = attachmentDownloadFilename(null, { name: '../../etc/passwd' });
expect(out).not.toContain('/');
// CHARACTERISATION: slashes → "_", then the leading "._-" run is stripped,
// so "../../etc/passwd" collapses to "etc_passwd".
expect(out).toBe('etc_passwd');
});
});
describe('bundleExportFilename', () => {
it('substitutes {count} and appends .zip', () => {
expect(bundleExportFilename(3, '{count}-emails', '2026-05-22T14:05:33Z')).toBe('3-emails.zip');
});
it('uses the default template', () => {
expect(bundleExportFilename(5, {}, '2026-05-22T14:05:33Z')).toBe('emails-5.zip');
});
});
describe('emailVars (date + address labels)', () => {
it('returns the invalid-date sentinel for an unparseable date', () => {
const v = emailVars(makeEmail({ receivedAt: 'not-a-date', sentAt: undefined }));
expect(v.date).toBe('0000-00-00 00.00.00');
expect(v.date_short).toBe('0000-00-00');
expect(v.time).toBe('00.00.00');
expect(v.year).toBe('0000');
});
it('addrLabel falls back name → email user-part → "unknown"', () => {
expect(emailVars(makeEmail({ from: [{ email: 'alice@example.com' }] }) ).from).toBe('alice');
expect(emailVars(makeEmail({ from: [] })).from).toBe('unknown');
expect(emailVars(makeEmail({ from: [{ name: ' ', email: 'x@y.com' }] })).from).toBe('x');
});
});
describe('attachmentVars (extension split)', () => {
it('splits name and ext on the last dot', () => {
const v = attachmentVars(buildSampleEmail(), { name: 'doc.tar.gz' });
expect(v).toMatchObject({ filename: 'doc.tar.gz', name: 'doc.tar', ext: 'gz' });
});
it('treats a dotless name as having no extension', () => {
const v = attachmentVars(buildSampleEmail(), { name: 'noext' });
expect(v).toMatchObject({ name: 'noext', ext: '' });
});
it('defaults a missing name to "attachment"', () => {
const v = attachmentVars(buildSampleEmail(), {});
expect(v).toMatchObject({ filename: 'attachment', ext: '' });
});
});