Follow-up hardening from two review passes on the on-device translation feature: - Privacy (HIGH): the translation cache is decrypted message plaintext, but logout did not clear it (unlike the search index), leaving up to 300 cleartext bodies in localStorage on shared devices. Add clearTranslationCache() and call it from both logout paths (logoutClient and the server-forced SessionLoggedOut handler). - Edited messages (MEDIUM): the cache key was eventId:target with no content dependence, so an edit reused the pre-edit translation. Fold a content fingerprint into the key, and re-arm the auto-translate one-shot when the text changes. - Settings (LOW): coerce a persisted translateTargetLang to a supported curated code so the hook never targets a language the engine can't produce (previously only the UI clamped it). - Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at the Translator API boundary, since normalizeLang lowercases the script subtag for internal keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
69 lines
2.3 KiB
TypeScript
69 lines
2.3 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
// The module evaluates atomWithStorage(..., { getOnInit: true }) which reads
|
|
// localStorage at load time. node has none — install a no-op mock, then import
|
|
// dynamically (a static import would hoist above the mock).
|
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
|
getItem: () => null,
|
|
setItem: () => undefined,
|
|
removeItem: () => undefined,
|
|
};
|
|
|
|
const { addTranslation, findTranslation, makeCacheKey } = await import('./translation');
|
|
|
|
const entry = (key: string, translated = `t-${key}`, fromLang = 'de') => ({
|
|
key,
|
|
translated,
|
|
fromLang,
|
|
});
|
|
|
|
test('makeCacheKey: eventId + normalized target + content fingerprint', () => {
|
|
// Same event + target + text is stable and prefixed by event:normalizedTarget.
|
|
const k1 = makeCacheKey('$abc', 'en-US', 'hola');
|
|
const k2 = makeCacheKey('$abc', 'EN', 'hola');
|
|
assert.equal(k1, k2);
|
|
assert.ok(k1.startsWith('$abc:en:'));
|
|
// Different body (an edit) => different key, so a stale translation misses.
|
|
assert.notEqual(makeCacheKey('$abc', 'en', 'hola'), makeCacheKey('$abc', 'en', 'adios'));
|
|
// Missing text arg still yields a stable key.
|
|
assert.ok(makeCacheKey('$abc', 'en').startsWith('$abc:en:'));
|
|
});
|
|
|
|
test('addTranslation: prepends, newest first', () => {
|
|
const out = addTranslation([entry('a'), entry('b')], entry('c'));
|
|
assert.deepEqual(
|
|
out.map((e) => e.key),
|
|
['c', 'a', 'b'],
|
|
);
|
|
});
|
|
|
|
test('addTranslation: de-dupes by key, moving to front (and updates value)', () => {
|
|
const out = addTranslation([entry('a', 'old'), entry('b')], entry('a', 'new'));
|
|
assert.deepEqual(
|
|
out.map((e) => e.key),
|
|
['a', 'b'],
|
|
);
|
|
assert.equal(out[0].translated, 'new');
|
|
});
|
|
|
|
test('addTranslation: caps at max (newest kept)', () => {
|
|
const out = addTranslation([entry('a'), entry('b'), entry('c')], entry('d'), 3);
|
|
assert.deepEqual(
|
|
out.map((e) => e.key),
|
|
['d', 'a', 'b'],
|
|
);
|
|
});
|
|
|
|
test('addTranslation: ignores empty key or translated', () => {
|
|
const start = [entry('a')];
|
|
assert.equal(addTranslation(start, entry('', 'x')), start);
|
|
assert.equal(addTranslation(start, entry('b', '')), start);
|
|
});
|
|
|
|
test('findTranslation: returns match or undefined', () => {
|
|
const list = [entry('a'), entry('b')];
|
|
assert.equal(findTranslation(list, 'b')?.key, 'b');
|
|
assert.equal(findTranslation(list, 'z'), undefined);
|
|
});
|