test(fls): e-mail account files against Swift's reader

The Swift oracle reads the 43 account files Java read (--email-files, with
TopologyRuntimeEmailStorage.decodeJavaServer and decodeJavaClient). The
accounts the model takes match Swift's except where Swift checks names,
hosts and addresses and fails the whole file on one it rejects, or reads
the format otherwise (the first client line instead of the last, exactly
nine fields, a required messages field). Every departure is named with its
reason; Java's reading is right in each, none is a Java error Swift fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:40:14 +00:00
co-authored by Claude Opus 5.5
parent 1efbb8a660
commit 956443726b
7 changed files with 910 additions and 2 deletions
+157
View File
@@ -0,0 +1,157 @@
// The e-mail account files as the model takes them, against Swift's reader
// (TopologyRuntimeEmailStorage.decodeJavaServer/decodeJavaClient,
// email-*.swift.json, tools/swift-oracle --email-files) on the cases Java
// read too (javaEmail.test.ts). Swift checks names, hosts and addresses and
// fails the whole file on one it rejects, where Java reads it; each such
// case is named with its reason and must really depart.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { EmailAddress, EmailMessage } from '@mdg-filius/core';
import { emailClientConfiguration, emailServerConfiguration } from '../src/importEmail.js';
import { readEmailClientFile, readEmailServerFile } from '../src/javaEmail.js';
import type { FlsApplication } from '../src/index.js';
const here = dirname(fileURLToPath(import.meta.url));
/** The cases file's escapes: a backslash, `u` and four hex digits for one UTF-16 unit. */
function unescape(line: string): string {
return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));
}
const fullName = (person: { firstName: string; lastName: string }) =>
[person.firstName, person.lastName].filter((part) => part !== '').join(' ');
function swiftMessage(message: EmailMessage) {
const address = (entry: EmailAddress) => [entry.name, entry.mailAddress];
return {
id: message.id,
from: address(message.from),
to: message.to.map(address),
cc: message.cc.map(address),
bcc: message.bcc.map(address),
subject: message.subject,
body: message.body,
deliveryIdentity: null,
receivedAtMilliseconds: message.receivedAtMilliseconds ?? null,
isNew: message.isNew,
isMarkedForDeletion: message.isMarkedForDeletion,
isSent: message.isSent,
};
}
function serverAccounts(content: string) {
const application = { fileAccounts: readEmailServerFile(content, () => {}), mailDomain: 'filius.de' } as FlsApplication;
return emailServerConfiguration(application, () => {}).accounts.map((account) => ({
username: account.username,
password: account.password,
name: fullName(account),
mailbox: account.mailbox.map(swiftMessage),
}));
}
function clientAccount(content: string) {
const application = { fileAccount: readEmailClientFile(content, () => {}) } as FlsApplication;
const account = emailClientConfiguration(application, () => {}).account;
if (account === null) return null;
const { firstName, lastName, ...fields } = account;
return { ...fields, name: fullName({ firstName, lastName }) };
}
type Departures = { reason: string; lines: string[] }[];
/**
* Swift's result where it means the same as ours: Swift numbers a mailbox's
* messages as their received time (a Swift error named in
* importProject.test.ts), and "no account" is an error to Swift.
*/
function comparable(expected: unknown): unknown {
if (expected === '!invalidAccount("missing client account")') return null;
if (!Array.isArray(expected)) return expected;
return expected.map((account: { mailbox: { id: number; receivedAtMilliseconds: number | null }[] }) => ({
...account,
mailbox: account.mailbox.map((message) =>
message.receivedAtMilliseconds === message.id ? { ...message, receivedAtMilliseconds: null } : message,
),
}));
}
function compareWithSwift(kind: 'server' | 'client', departures: Departures, read: (content: string) => unknown) {
const cases = JSON.parse(readFileSync(join(here, `email-${kind}.swift.json`), 'utf8')) as [string, unknown][];
const lines = readFileSync(join(here, `email-${kind}.cases.txt`), 'ascii').split('\n').slice(0, -1);
const byLine = new Map(departures.flatMap(({ reason, lines: named }) => named.map((line) => [line, reason] as const)));
it('covers every line of the cases file and names only its lines', () => {
expect(cases.map(([line]) => line)).toEqual(lines);
expect([...byLine.keys()].filter((line) => !lines.includes(line))).toEqual([]);
});
for (const [line, expected] of cases) {
const reason = byLine.get(line);
it(reason === undefined ? JSON.stringify(line) : `${JSON.stringify(line)} (departs: ${reason})`, () => {
if (reason === undefined) expect(read(unescape(line))).toEqual(comparable(expected));
else expect(read(unescape(line))).not.toEqual(comparable(expected));
});
}
}
const SERVER_DEPARTURES: Departures = [
{
reason: 'a line without the messages field is an account, as in Java; Swift wants the sixth field and fails the file',
lines: [String.raw`bob;filius.de;bob;Nachname;Vorname`, String.raw`bob;filius.de;bob;Nachname;Vorname\u000D\u000A`],
},
{
reason: "the file's domain column is not used (Java takes the server's); Swift fails the file on an empty one",
lines: [String.raw`bob;;bob;Nachname;Vorname;\u000A`],
},
{
reason: 'a short line alone is left out; Swift fails the whole file',
lines: [String.raw`bob;filius.de;bob;Nachname\u000A`, String.raw`bob;filius.de;bob;Nachname;Vorname;\u000Abroken\u000A`],
},
{
reason: 'a user name stays as Java reads it; Swift rejects one with spaces and fails the file',
lines: [String.raw` bob ;filius.de; bob ;Nachname;Vorname;\u000A`],
},
{
reason: 'a message without seven fields alone is left out; Swift fails the whole file',
lines: [String.raw`bob;filius.de;bob;Nachname;Vorname;#Alice <alice@senior.de>$bob@filius.de$$$$Hi $5$Text\u000A`],
},
{
reason: "addresses as Java reads them (blank entries skipped, a name alone, Java's trim); Swift fails the file",
lines: [
String.raw`bob;filius.de;bob;Nachname;Vorname;#a@x$b@y, ,c@z,$$$$Hi$Text\u000A`,
String.raw`bob;filius.de;bob;Nachname;Vorname;#Name <a@x$b@y$$$$Hi$Text\u000A`,
String.raw`bob;filius.de;bob;Nachname;Vorname;#Only Name$b@y$$$$Hi$Text\u000A`,
String.raw`bob;filius.de;bob;Nachname;Vorname;#\u001Fa@x $b@y$$$$Hi$Text\u000A`,
],
},
];
const CLIENT_DEPARTURES: Departures = [
{
reason: 'the last complete line is the account, as in Java; Swift takes the first',
lines: [String.raw`a;a;110;25;first;p;L;F;first@x\u000Ab;b;110;25;second;p;L;F;second@x\u000A`],
},
{
reason: 'further fields are ignored, as in Java; Swift wants exactly nine',
lines: [String.raw`a;b;110;25;u;p;L;F;u@x;extra\u000A`],
},
{
reason: 'an account stays as Java reads it (a port that is no number is the default); Swift checks hosts, ports and the address and has none',
lines: [
String.raw`a;b;110;25;u;p;L;F;\u000A`,
String.raw`a;;110;25;u;p;;;u@x\u000A`,
String.raw` a ; b ;x;y; u ; p ; L ; F ; u@x \u000A`,
String.raw`;;;;;;;;`,
String.raw`;;;;;;;;x`,
],
},
];
describe('the server account file against Swift', () => {
compareWithSwift('server', SERVER_DEPARTURES, serverAccounts);
});
describe('the client account file against Swift', () => {
compareWithSwift('client', CLIENT_DEPARTURES, clientAccount);
});