// The e-mail program's and the e-mail server's windows against Java's // (tools/java-oracle MailWindowRun) on mail-lan's network: the same server // switched, its domain set, accounts made and removed; the same account set // in the program, mails written, sent, fetched, answered and deleted — the // same rows, previews, files and lines after each. What Java's window checks // before it acts (a form's addresses, a user name), the editor's panel checks // with the core's functions; here they are compared with Java's dialogs. // Named where the windows are better than Java's: // - a user name the server has already is refused (Java's window says it // made it), // - deleting deletes the mail chosen in the folder shown (Java's deleted a // mail of the other folder, where one was chosen before), // - a new domain is written into the accounts file at once (Java writes it // with the next change of the accounts). import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { accountOfForm, mailFormProblem, NetworkRuntimeEngine, splitName, VirtualFileSystem, withSnapshot, type AccountForm, type MailForm } from '@mdg-filius/core'; import { inboxRow, replyForm, sentRow } from '@mdg-filius/protocol'; import { snapshotOf, type Scenario } from '../../core/test/scenario.js'; interface JavaStep { readonly step: string; readonly [field: string]: unknown; } const here = dirname(fileURLToPath(import.meta.url)); const java = JSON.parse(readFileSync(join(here, 'mailWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; const scenario = JSON.parse(readFileSync(join(here, '../../core/test/scenarios/mail-lan.scenario.json'), 'utf8')) as Scenario; const CLIENT = '00000000-0000-0000-0000-000000000201'; const SERVER = '00000000-0000-0000-0000-000000000203'; const step = (name: string) => java.steps.find((each) => each.step === name)!; /** Java's window's placeholders for a new account's names (emailserver_msg21, msg20). */ const PLACEHOLDER = { firstName: 'Vorname', lastName: 'Nachname' }; function setUp() { const engine = new NetworkRuntimeEngine(scenario.seed); const serverFiles = new VirtualFileSystem(); const clientFiles = new VirtualFileSystem(); const snapshot = withSnapshot(snapshotOf(scenario), { emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: [], isActive: false }]]), emailClientsByNodeId: new Map([[CLIENT, { account: null, inbox: [], sent: [], drafts: [], addressBook: [], nextMessageID: 1 }]]), fileSystemsByNodeId: new Map([ [SERVER, serverFiles], [CLIENT, clientFiles], ]), }); engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 }); const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds }); return { engine, server: engine.emailServer(SERVER)!, client: engine.emailClient(CLIENT)!, serverFiles, clientFiles, run }; } /** The server's window as Java's shows it: button, domain field, rows of address and number of mails. */ function serverWindow(context: ReturnType) { const { server } = context; return { button: server.isActive ? 'Anhalten' : 'Starten', domain: server.domain, domainEnabled: !server.isActive, accounts: server.state.accounts.map((account) => [`${account.username}@${server.domain}`, String(account.mailbox.length)]), accountsFile: context.serverFiles.textFile('/mailserver/konten.txt'), }; } const javaServerWindow = (each: JavaStep) => ({ button: each.button, domain: each.domain, domainEnabled: each.domainEnabled, accounts: each.accounts, accountsFile: each.accountsFile }); /** What Java's server window checks before it makes an account, as the editor's panel checks it (emailserver_msg25, msg24). */ function serverFormProblem(username: string, password: string): 'missing' | 'whiteSpace' | undefined { if (username === '' || password === '') return 'missing'; return /[ \t\n\v\f\r]/.test(username) ? 'whiteSpace' : undefined; } const ACCOUNT_FORM: AccountForm = { name: 'Anna Maria Muster', email: 'anna@schule.de', pop3Host: '192.168.0.30', pop3Port: '110', smtpHost: '192.168.0.30', smtpPort: '25', username: 'anna', password: 'geheim', }; const MAIL_FORMS: Record = { invalid: { to: 'bob@schule.de, kein', cc: '', bcc: '', subject: 'Falsch', text: 'x' }, empty: { to: '', cc: '', bcc: '', subject: 'Leer', text: 'x' }, hello: { to: 'bob@schule.de', cc: '', bcc: '', subject: 'Hallo', text: 'Zeile 1\nZeile 2' }, self: { to: 'anna@schule.de', cc: '', bcc: '', subject: 'Selbst', text: 'Erste Zeile\nZweite Zeile' }, }; describe("the e-mail windows, against Java's", () => { it("switch the server, set its domain and make its accounts as Java's window does", () => { const context = setUp(); const { server } = context; expect(serverWindow(context)).toEqual(javaServerWindow(step('server:open'))); expect(server.setDomain('ungültig domain!')).toBe(false); expect(serverWindow(context)).toEqual(javaServerWindow(step('server:domain ungültig domain!'))); expect(server.setDomain('schule.de')).toBe(true); expect(serverWindow(context)).toEqual({ ...javaServerWindow(step('server:domain schule.de')), accountsFile: '' }); server.setActive(true, true); expect(serverWindow(context)).toEqual(javaServerWindow(step('server:start'))); expect(server.log.all.map((line) => line.kind)).toEqual(['switched']); const dialogs: Record = { missing: 'Geben Sie einen Benutzernamen und ein Passwort ein!', whiteSpace: 'Der Benutzername darf keine Leerzeichen enthalten!', }; for (const [username, password] of [['anna', 'geheim'], ['', 'x'], ['carl os', 'x'], ['ANNA', 'anders'], ['bob', ''], ['bob', 'pw']] as const) { const recorded = step(`server:add ${username}/${password}`); const problem = serverFormProblem(username, password); if (problem !== undefined) { expect(recorded.dialogs, username).toEqual([dialogs[problem]]); } else { const made = server.addAccount({ username, password, ...PLACEHOLDER }); // Java's window says it made "ANNA"; the server has "anna" already and takes nothing. expect(recorded.dialogs).toEqual([`Das Benutzerkonto ${username} wurde angelegt.`]); expect(made, username).toBe(username !== 'ANNA'); } expect(serverWindow(context), username).toEqual(javaServerWindow(recorded)); } }); it("set the program's account, write and send its mails, fetch, answer and delete them as Java's window does", () => { const context = setUp(); const { server, client, clientFiles, run } = context; server.setDomain('schule.de'); server.setActive(true, true); server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER }); server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER }); expect(client.account).toBeNull(); for (const [username, port] of [['an na', '110'], ['anna', '110x'], ['anna', '110']] as const) { const recorded = step(`client:account ${username} ${port}`); const account = accountOfForm({ ...ACCOUNT_FORM, username, pop3Port: port }); if (account === undefined) expect(recorded.dialogs).toEqual(['Die Angaben sind entweder fehlerhaft oder unvollständig!']); else client.setAccount(account); expect(clientFiles.contains('/konten.txt') ? clientFiles.textFile('/konten.txt') : null, username + port).toBe(recorded.accountFile); } // Opened again, the dialog shows the account: first and last name with a space between. const shown = client.account!; expect([`${shown.firstName} ${shown.lastName}`, shown.email, shown.pop3Host, String(shown.pop3Port), shown.smtpHost, String(shown.smtpPort), shown.username, shown.password]).toEqual( step('client:account shown').fields, ); const problems: Record = { invalidAddress: 'Mindestens eine eingegebene E-Mail-Adresse ist nicht korrekt!', noRecipient: 'Sie müssen mindestens eine Empfänger-Adresse eingeben!', }; for (const form of Object.values(MAIL_FORMS)) { const name = form.subject; const recorded = step(`client:send ${name}`); const problem = mailFormProblem(form, true); if (problem !== undefined) { expect(recorded.dialogs, name).toEqual([problems[problem]]); // The compose window stays open with what was typed. expect([recorded.composing, recorded.composeFields, recorded.composeText, recorded.composeSender]).toEqual([true, [form.to, form.cc, form.bcc, form.subject], form.text, 'anna']); } else { expect(client.send(form)).toBeUndefined(); run(20_000); // Sent: the compose window closes, the window shows the sent mails. expect([recorded.composing, recorded.tab]).toEqual([false, 1]); } expect(client.sent.map((entry) => sentRow(entry.mail)), name).toEqual(recorded.sent); } expect(serverWindow(context)).toEqual(javaServerWindow(step('server:after mails'))); client.fetch(); run(20_000); const fetched = step('client:fetch'); expect([client.inbox.map((entry) => inboxRow(entry.mail)), fetched.tab]).toEqual([fetched.inbox, 0]); expect(client.inbox[0]!.mail.text).toBe(step('client:choose inbox 0').preview); const answer = replyForm(client.inbox[0]!.mail, { re: 'RE:', wrote: 'schrieb:' }); const answered = step('client:answer'); expect([[answer.to, '', '', answer.subject], answer.text, 'anna']).toEqual([answered.composeFields, answered.composeText, answered.composeSender]); expect(client.sent[0]!.mail.text).toBe(step('client:choose sent 0').preview); expect(serverWindow(context)).toEqual(javaServerWindow(step('server:after fetch'))); // The mail chosen in the inbox is deleted (Java deleted the sent mail chosen before). client.removeMail('inbox', 0); const deleted = step('client:delete inbox 0'); expect(client.inbox).toEqual([]); expect(deleted.inbox).toEqual(fetched.inbox); expect(client.sent.map((entry) => sentRow(entry.mail))).toEqual((fetched.sent as unknown[]).slice()); expect(deleted.sent).toEqual((fetched.sent as unknown[]).slice(1)); // A row it has not: nothing. client.removeMail('sent', 5); client.removeMail('sent', -1); expect(client.sent).toHaveLength(2); }); it("remove an account, stop the server and set its domain while it is off, as Java's window does", () => { const context = setUp(); const { server } = context; server.setDomain('schule.de'); server.setActive(true, true); server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER }); server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER }); expect(step('server:delete row 1').dialogs).toEqual(["Wollen Sie das Konto 'bob' wirklich entfernen?"]); expect(server.removeAccount('bob')).toBe(true); expect(server.removeAccount('bob')).toBe(false); expect(server.removeAccount('ANNA')).toBe(false); expect(serverWindow(context)).toEqual(javaServerWindow(step('server:delete row 1'))); server.setActive(false, true); expect(serverWindow(context)).toEqual(javaServerWindow(step('server:stop'))); expect(server.log.all.map((line) => line.kind)).toEqual(['switched', 'switched']); expect(server.log.all.map((line) => line.kind === 'switched' && line.active)).toEqual([true, false]); expect(server.setDomain('neu.de')).toBe(true); // The new domain goes into the file at once (Java's keeps the old one until the accounts change). const recorded = step('server:domain neu.de'); expect(serverWindow(context)).toEqual({ ...javaServerWindow(recorded), accountsFile: 'anna;neu.de;geheim;Nachname;Vorname;\n' }); expect(recorded.accountsFile).toBe('anna;schule.de;geheim;Nachname;Vorname;\n'); expect(server.setDomain('')).toBe(false); expect(server.domain).toBe('neu.de'); }); it("split a name as Java's account dialog does", () => { expect(splitName('Anna Maria Muster')).toEqual({ firstName: 'Anna Maria', lastName: 'Muster' }); expect(splitName('Anna')).toEqual({ firstName: 'Anna', lastName: '' }); expect(splitName(' Anna ')).toEqual({ firstName: '', lastName: 'Anna' }); expect(splitName('Anna ')).toEqual({ firstName: 'Anna', lastName: '' }); expect(splitName(' ')).toEqual({ firstName: '', lastName: '' }); expect(accountOfForm({ ...ACCOUNT_FORM, username: '', smtpPort: '65535', pop3Port: '1' })).toMatchObject({ username: '', smtpPort: 65535, pop3Port: 1 }); for (const port of ['0', '65536', '', '080', ' 25']) expect(accountOfForm({ ...ACCOUNT_FORM, smtpPort: port }), port).toBeUndefined(); expect(accountOfForm({ ...ACCOUNT_FORM, username: 'a.b_c-d9' })).toBeDefined(); expect(accountOfForm({ ...ACCOUNT_FORM, username: 'anna@x' })).toBeUndefined(); }); it('deletes a sent mail, sends with the account a mail was written with, and keeps a new domain in the snapshot', () => { const context = setUp(); const { engine, server, client, run } = context; server.setDomain('schule.de'); expect(engine.state.topologySnapshot.emailServersByNodeId.get(SERVER)!.domain).toBe('schule.de'); server.setActive(true, true); server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER }); server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER }); client.setAccount(accountOfForm(ACCOUNT_FORM)!); expect(client.send({ ...MAIL_FORMS.hello!, to: 'bob@schule.de, anna@schule.de' })).toBeUndefined(); // Changed before the mail goes to its second recipient: it still goes with the account it was written with. client.setAccount(accountOfForm({ ...ACCOUNT_FORM, smtpHost: 'niemand.de' })!); run(40_000); expect(client.sent.map((entry) => entry.mail.subject)).toEqual(['Hallo']); expect(server.state.accounts.map((account) => account.mailbox.length)).toEqual([1, 1]); client.setAccount(accountOfForm(ACCOUNT_FORM)!); expect(client.send(MAIL_FORMS.self!)).toBeUndefined(); run(20_000); client.removeMail('sent', 0); expect(client.sent.map((entry) => entry.mail.subject)).toEqual(['Selbst']); expect(engine.state.topologySnapshot.emailClientsByNodeId.get(CLIENT)!.sent.map((message) => message.subject)).toEqual(['Selbst']); }); });