@remit/config-transfer 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/binder.ts +184 -0
- package/src/export.test.ts +51 -1
- package/src/export.ts +96 -10
- package/src/fixtures.ts +17 -0
- package/src/import-repositories.ts +97 -0
- package/src/import.test.ts +1200 -0
- package/src/import.ts +1165 -0
- package/src/index.ts +34 -1
- package/src/report.ts +69 -0
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
AccountConfigItem,
|
|
5
|
+
AccountItem,
|
|
6
|
+
AccountSettingItem,
|
|
7
|
+
AddressItem,
|
|
8
|
+
ConfigImportItem,
|
|
9
|
+
FilterAnchorItem,
|
|
10
|
+
FilterItem,
|
|
11
|
+
LabelItem,
|
|
12
|
+
MailboxItem,
|
|
13
|
+
} from "@remit/data-ports";
|
|
14
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
15
|
+
import {
|
|
16
|
+
composeFolderRoleAppointmentLabelName,
|
|
17
|
+
composeFolderRoleAppointmentName,
|
|
18
|
+
} from "@remit/data-ports/folder-role";
|
|
19
|
+
import { deriveAddressId } from "@remit/data-ports/id";
|
|
20
|
+
import { bindImportedFolders, pendingImportOf } from "./binder.js";
|
|
21
|
+
import { readConfigForExport } from "./export.js";
|
|
22
|
+
import {
|
|
23
|
+
ACCOUNT_CONFIG_ID,
|
|
24
|
+
asRepositories,
|
|
25
|
+
type ConfigFixture,
|
|
26
|
+
makeAccount,
|
|
27
|
+
makeAccountConfig,
|
|
28
|
+
makeAddress,
|
|
29
|
+
makeAnchor,
|
|
30
|
+
makeFilter,
|
|
31
|
+
makeLabel,
|
|
32
|
+
makeLegacyFlaggedAddress,
|
|
33
|
+
makeMailbox,
|
|
34
|
+
makeSetting,
|
|
35
|
+
OAUTH_ACCOUNT_ID,
|
|
36
|
+
PASSWORD_ACCOUNT_ID,
|
|
37
|
+
} from "./fixtures.js";
|
|
38
|
+
import { ANCHOR_EMBEDDING_PENDING, importConfig } from "./import.js";
|
|
39
|
+
import type { ConfigImportDeps } from "./import-repositories.js";
|
|
40
|
+
|
|
41
|
+
const IDENTITY = {
|
|
42
|
+
app: "reader",
|
|
43
|
+
version: "v0.1.0",
|
|
44
|
+
exportedAt: "2026-08-27T09:15:00+02:00",
|
|
45
|
+
instance: "reader.ischen.nl",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const TARGET_CONFIG_ID = "1a2b3c4d5e6f7a8b9c0d1e2f3";
|
|
49
|
+
const TARGET_USER_ID = "9f8e7d6c-5b4a-4938-8271-605f4e3d2c1b";
|
|
50
|
+
const EMBEDDING_ID = "amazon.titan-embed-text-v2:0@1024";
|
|
51
|
+
|
|
52
|
+
const INBOX_ID = "mbx-inbox";
|
|
53
|
+
const SENT_ID = "mbx-sent";
|
|
54
|
+
const INVOICES_ID = "mbx-invoices";
|
|
55
|
+
const LISTS_ID = "mbx-lists";
|
|
56
|
+
const OAUTH_INBOX_ID = "mbx-oauth-inbox";
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// An in-memory configuration, behind the repositories both halves take. What is
|
|
60
|
+
// under test is which decisions cross and which never do, and a store is not
|
|
61
|
+
// what decides either.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
interface Store {
|
|
65
|
+
accountConfigs: Map<string, AccountConfigItem>;
|
|
66
|
+
accounts: AccountItem[];
|
|
67
|
+
settings: Map<string, AccountSettingItem>;
|
|
68
|
+
mailboxes: MailboxItem[];
|
|
69
|
+
labels: LabelItem[];
|
|
70
|
+
filters: FilterItem[];
|
|
71
|
+
anchors: FilterAnchorItem[];
|
|
72
|
+
addresses: AddressItem[];
|
|
73
|
+
imports: ConfigImportItem[];
|
|
74
|
+
/** Set to make the next write of this kind fail, for the abort path. */
|
|
75
|
+
failOn?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const NOW = 1756281600000;
|
|
79
|
+
let ids = 0;
|
|
80
|
+
const nextId = (prefix: string): string => `${prefix}-${++ids}`;
|
|
81
|
+
|
|
82
|
+
const emptyStore = (): Store => ({
|
|
83
|
+
accountConfigs: new Map(),
|
|
84
|
+
accounts: [],
|
|
85
|
+
settings: new Map(),
|
|
86
|
+
mailboxes: [],
|
|
87
|
+
labels: [],
|
|
88
|
+
filters: [],
|
|
89
|
+
anchors: [],
|
|
90
|
+
addresses: [],
|
|
91
|
+
imports: [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const storeFromFixture = (fixture: ConfigFixture): Store => ({
|
|
95
|
+
accountConfigs: new Map([
|
|
96
|
+
[fixture.accountConfig.accountConfigId, fixture.accountConfig],
|
|
97
|
+
]),
|
|
98
|
+
accounts: [...fixture.accounts],
|
|
99
|
+
settings: new Map(
|
|
100
|
+
fixture.settings.map((setting) => [setting.name, setting] as const),
|
|
101
|
+
),
|
|
102
|
+
mailboxes: [...fixture.mailboxes],
|
|
103
|
+
labels: [...fixture.labels],
|
|
104
|
+
filters: [...fixture.filters],
|
|
105
|
+
anchors: [...fixture.anchors],
|
|
106
|
+
addresses: [...fixture.addresses],
|
|
107
|
+
imports: [],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// biome-ignore lint/suspicious/noExplicitAny: one fake standing in for every port
|
|
111
|
+
const repositoriesOf = (store: Store, accountConfigId: string): any => {
|
|
112
|
+
const fail = (kind: string): void => {
|
|
113
|
+
if (store.failOn === kind) throw new Error(`store refused a ${kind} write`);
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
accountConfig: {
|
|
117
|
+
get: async (id: string) => {
|
|
118
|
+
const held = store.accountConfigs.get(id);
|
|
119
|
+
if (!held) throw new NotFoundError(`AccountConfig not found: ${id}`);
|
|
120
|
+
return held;
|
|
121
|
+
},
|
|
122
|
+
create: async (input: {
|
|
123
|
+
accountConfigId?: string;
|
|
124
|
+
userId: string;
|
|
125
|
+
name?: string;
|
|
126
|
+
}) => {
|
|
127
|
+
const row: AccountConfigItem = {
|
|
128
|
+
accountConfigId: input.accountConfigId ?? nextId("cfg"),
|
|
129
|
+
userId: input.userId,
|
|
130
|
+
name: input.name ?? "",
|
|
131
|
+
state: "active",
|
|
132
|
+
createdAt: NOW,
|
|
133
|
+
updatedAt: NOW,
|
|
134
|
+
};
|
|
135
|
+
store.accountConfigs.set(row.accountConfigId, row);
|
|
136
|
+
return row;
|
|
137
|
+
},
|
|
138
|
+
update: async (id: string, patch: Partial<AccountConfigItem>) => {
|
|
139
|
+
const held = store.accountConfigs.get(id);
|
|
140
|
+
if (!held) throw new NotFoundError(`AccountConfig not found: ${id}`);
|
|
141
|
+
const next = { ...held, ...patch };
|
|
142
|
+
store.accountConfigs.set(id, next);
|
|
143
|
+
return next;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
account: {
|
|
147
|
+
listAllByAccountConfig: async () =>
|
|
148
|
+
store.accounts.filter(
|
|
149
|
+
(account) => account.accountConfigId === accountConfigId,
|
|
150
|
+
),
|
|
151
|
+
create: async (input: Partial<AccountItem>) => {
|
|
152
|
+
fail("account");
|
|
153
|
+
const row = makeAccount({
|
|
154
|
+
...input,
|
|
155
|
+
accountConfigId,
|
|
156
|
+
accountId: input.accountId ?? nextId("acc"),
|
|
157
|
+
} as Partial<AccountItem> & Pick<AccountItem, "accountId">);
|
|
158
|
+
store.accounts.push(row);
|
|
159
|
+
return row;
|
|
160
|
+
},
|
|
161
|
+
update: async (accountId: string, patch: Partial<AccountItem>) => {
|
|
162
|
+
const at = store.accounts.findIndex(
|
|
163
|
+
(account) => account.accountId === accountId,
|
|
164
|
+
);
|
|
165
|
+
if (at === -1)
|
|
166
|
+
throw new NotFoundError(`Account not found: ${accountId}`);
|
|
167
|
+
const next = { ...store.accounts[at], ...patch };
|
|
168
|
+
store.accounts[at] = next;
|
|
169
|
+
return next;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
accountSetting: {
|
|
173
|
+
listByAccountConfig: async () => [...store.settings.values()],
|
|
174
|
+
upsert: async (input: {
|
|
175
|
+
name: string;
|
|
176
|
+
value: AccountSettingItem["value"];
|
|
177
|
+
}) => {
|
|
178
|
+
const row = makeSetting(input.name, input.value);
|
|
179
|
+
store.settings.set(input.name, row);
|
|
180
|
+
return row;
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
mailbox: {
|
|
184
|
+
listAllByAccount: async (accountId: string) =>
|
|
185
|
+
store.mailboxes.filter((mailbox) => mailbox.accountId === accountId),
|
|
186
|
+
},
|
|
187
|
+
label: {
|
|
188
|
+
listByAccountConfig: async () => [...store.labels],
|
|
189
|
+
create: async (input: { name: string; color?: LabelItem["color"] }) => {
|
|
190
|
+
const row = makeLabel({
|
|
191
|
+
labelId: nextId("lbl"),
|
|
192
|
+
name: input.name,
|
|
193
|
+
color: input.color ?? "Default",
|
|
194
|
+
accountConfigId,
|
|
195
|
+
});
|
|
196
|
+
store.labels.push(row);
|
|
197
|
+
return row;
|
|
198
|
+
},
|
|
199
|
+
update: async (
|
|
200
|
+
_configId: string,
|
|
201
|
+
labelId: string,
|
|
202
|
+
patch: Partial<LabelItem>,
|
|
203
|
+
) => {
|
|
204
|
+
const at = store.labels.findIndex((label) => label.labelId === labelId);
|
|
205
|
+
const next = { ...store.labels[at], ...patch };
|
|
206
|
+
store.labels[at] = next;
|
|
207
|
+
return next;
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
filter: {
|
|
211
|
+
listByAccountConfig: async () => [...store.filters],
|
|
212
|
+
create: async (input: Partial<FilterItem> & { name: string }) => {
|
|
213
|
+
fail("filter");
|
|
214
|
+
const row = makeFilter({
|
|
215
|
+
...input,
|
|
216
|
+
accountConfigId,
|
|
217
|
+
filterId: nextId("flt"),
|
|
218
|
+
});
|
|
219
|
+
store.filters.push(row);
|
|
220
|
+
return row;
|
|
221
|
+
},
|
|
222
|
+
update: async (
|
|
223
|
+
_configId: string,
|
|
224
|
+
filterId: string,
|
|
225
|
+
patch: Partial<FilterItem>,
|
|
226
|
+
) => {
|
|
227
|
+
const at = store.filters.findIndex(
|
|
228
|
+
(filter) => filter.filterId === filterId,
|
|
229
|
+
);
|
|
230
|
+
const next = { ...store.filters[at], ...patch };
|
|
231
|
+
store.filters[at] = next;
|
|
232
|
+
return next;
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
filterAnchor: {
|
|
236
|
+
put: async (input: Partial<FilterAnchorItem> & { filterId: string }) => {
|
|
237
|
+
const row = makeAnchor({ ...input, accountConfigId });
|
|
238
|
+
store.anchors = [
|
|
239
|
+
...store.anchors.filter(
|
|
240
|
+
(anchor) => anchor.filterId !== input.filterId,
|
|
241
|
+
),
|
|
242
|
+
row,
|
|
243
|
+
];
|
|
244
|
+
return row;
|
|
245
|
+
},
|
|
246
|
+
listByAccountConfig: async () => [...store.anchors],
|
|
247
|
+
},
|
|
248
|
+
address: {
|
|
249
|
+
listByAccountConfig: async () => ({
|
|
250
|
+
items: [...store.addresses],
|
|
251
|
+
continuationToken: undefined,
|
|
252
|
+
}),
|
|
253
|
+
upsertAddress: async (
|
|
254
|
+
input: Partial<AddressItem> & {
|
|
255
|
+
addressId: string;
|
|
256
|
+
normalizedEmail: string;
|
|
257
|
+
},
|
|
258
|
+
) => {
|
|
259
|
+
const at = store.addresses.findIndex(
|
|
260
|
+
(address) => address.addressId === input.addressId,
|
|
261
|
+
);
|
|
262
|
+
const row = makeAddress({
|
|
263
|
+
...(at === -1 ? {} : store.addresses[at]),
|
|
264
|
+
...input,
|
|
265
|
+
accountConfigId,
|
|
266
|
+
});
|
|
267
|
+
if (at === -1) store.addresses.push(row);
|
|
268
|
+
else store.addresses[at] = row;
|
|
269
|
+
return row;
|
|
270
|
+
},
|
|
271
|
+
mergeFlags: async (
|
|
272
|
+
_configId: string,
|
|
273
|
+
addressId: string,
|
|
274
|
+
patch: Record<string, unknown>,
|
|
275
|
+
) => {
|
|
276
|
+
const at = store.addresses.findIndex(
|
|
277
|
+
(address) => address.addressId === addressId,
|
|
278
|
+
);
|
|
279
|
+
if (at === -1)
|
|
280
|
+
throw new NotFoundError(`Address not found: ${addressId}`);
|
|
281
|
+
const next = {
|
|
282
|
+
...store.addresses[at],
|
|
283
|
+
flags: { ...store.addresses[at].flags, ...patch },
|
|
284
|
+
};
|
|
285
|
+
store.addresses[at] = next as AddressItem;
|
|
286
|
+
return next;
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
configImport: {
|
|
290
|
+
create: async (input: Partial<ConfigImportItem>) => {
|
|
291
|
+
const row = {
|
|
292
|
+
importId: nextId("imp"),
|
|
293
|
+
accountConfigId,
|
|
294
|
+
schemaVersion: 1,
|
|
295
|
+
state: "Pending",
|
|
296
|
+
document: {},
|
|
297
|
+
unresolvedRefs: [],
|
|
298
|
+
createdAt: NOW,
|
|
299
|
+
completedAt: 0,
|
|
300
|
+
updatedAt: NOW,
|
|
301
|
+
...input,
|
|
302
|
+
} as ConfigImportItem;
|
|
303
|
+
store.imports.push(row);
|
|
304
|
+
return row;
|
|
305
|
+
},
|
|
306
|
+
update: async (importId: string, patch: Partial<ConfigImportItem>) => {
|
|
307
|
+
const at = store.imports.findIndex((row) => row.importId === importId);
|
|
308
|
+
const next = { ...store.imports[at], ...patch };
|
|
309
|
+
store.imports[at] = next;
|
|
310
|
+
return next;
|
|
311
|
+
},
|
|
312
|
+
listByAccountConfig: async () => [...store.imports],
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The one writer of a folder-role appointment, stood in for. The pair of rows
|
|
319
|
+
* moves together, the way `writeFolderRoleAppointment` writes them.
|
|
320
|
+
*/
|
|
321
|
+
const appointFolderRoleInto =
|
|
322
|
+
(store: Store) =>
|
|
323
|
+
async (
|
|
324
|
+
_accountConfigId: string,
|
|
325
|
+
accountId: string,
|
|
326
|
+
role: string,
|
|
327
|
+
mailboxId: string,
|
|
328
|
+
lastKnownPath: string,
|
|
329
|
+
): Promise<void> => {
|
|
330
|
+
const named = composeFolderRoleAppointmentName(accountId, role as "Sent");
|
|
331
|
+
const labelled = composeFolderRoleAppointmentLabelName(
|
|
332
|
+
accountId,
|
|
333
|
+
role as "Sent",
|
|
334
|
+
);
|
|
335
|
+
store.settings.set(
|
|
336
|
+
named,
|
|
337
|
+
makeSetting(named, { kind: "String", value: mailboxId }),
|
|
338
|
+
);
|
|
339
|
+
store.settings.set(
|
|
340
|
+
labelled,
|
|
341
|
+
makeSetting(labelled, { kind: "String", value: lastKnownPath }),
|
|
342
|
+
);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A transaction that actually rolls back. The backend's `writeSet` discards
|
|
347
|
+
* every write on a throw, so a fake that merely called through would let an
|
|
348
|
+
* atomicity test pass over a store that had kept half a document — which is the
|
|
349
|
+
* one thing these tests exist to catch. Each collection is restored to the
|
|
350
|
+
* instance it had on entry.
|
|
351
|
+
*/
|
|
352
|
+
const transactionOver =
|
|
353
|
+
(store: Store) =>
|
|
354
|
+
async <T>(run: () => Promise<T>): Promise<T> => {
|
|
355
|
+
const before = {
|
|
356
|
+
accountConfigs: new Map(store.accountConfigs),
|
|
357
|
+
accounts: [...store.accounts],
|
|
358
|
+
settings: new Map(store.settings),
|
|
359
|
+
mailboxes: [...store.mailboxes],
|
|
360
|
+
labels: [...store.labels],
|
|
361
|
+
filters: [...store.filters],
|
|
362
|
+
anchors: [...store.anchors],
|
|
363
|
+
addresses: [...store.addresses],
|
|
364
|
+
imports: [...store.imports],
|
|
365
|
+
};
|
|
366
|
+
return run().catch((error: unknown) => {
|
|
367
|
+
Object.assign(store, before);
|
|
368
|
+
throw error;
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const depsOf = (
|
|
373
|
+
store: Store,
|
|
374
|
+
accountConfigId: string,
|
|
375
|
+
embed = true,
|
|
376
|
+
): ConfigImportDeps => ({
|
|
377
|
+
repositories: repositoriesOf(store, accountConfigId),
|
|
378
|
+
appointFolderRole: appointFolderRoleInto(store),
|
|
379
|
+
transaction: transactionOver(store),
|
|
380
|
+
embedAnchor: embed
|
|
381
|
+
? async () => ({ embedding: [0.11, 0.22, 0.33], embeddingId: EMBEDDING_ID })
|
|
382
|
+
: undefined,
|
|
383
|
+
now: () => NOW,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// A configuration worth carrying across: two accounts, both auth types, folder
|
|
388
|
+
// roles, per-folder overrides, labels, a literal and a semantic filter, and two
|
|
389
|
+
// flagged senders.
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
const sourceFixture = (): ConfigFixture => ({
|
|
393
|
+
accountConfig: makeAccountConfig(),
|
|
394
|
+
accounts: [
|
|
395
|
+
makeAccount({ accountId: PASSWORD_ACCOUNT_ID }),
|
|
396
|
+
makeAccount({
|
|
397
|
+
accountId: OAUTH_ACCOUNT_ID,
|
|
398
|
+
email: "work@example.com",
|
|
399
|
+
username: "work@example.com",
|
|
400
|
+
authType: "oauthMicrosoft",
|
|
401
|
+
imapHost: "outlook.office365.com",
|
|
402
|
+
smtpEnabled: false,
|
|
403
|
+
smtpHost: "",
|
|
404
|
+
}),
|
|
405
|
+
],
|
|
406
|
+
settings: [
|
|
407
|
+
makeSetting("DefaultComposerFormat", {
|
|
408
|
+
kind: "String",
|
|
409
|
+
value: "markdown",
|
|
410
|
+
}),
|
|
411
|
+
makeSetting(`AccountDisplayName#${PASSWORD_ACCOUNT_ID}`, {
|
|
412
|
+
kind: "String",
|
|
413
|
+
value: "Matthijs van Henten",
|
|
414
|
+
}),
|
|
415
|
+
makeSetting(`AccountComposeLanguages#${PASSWORD_ACCOUNT_ID}`, {
|
|
416
|
+
kind: "StringList",
|
|
417
|
+
value: ["nl-NL", "en-GB"],
|
|
418
|
+
}),
|
|
419
|
+
makeSetting(`AccountSignaturePlainText#${PASSWORD_ACCOUNT_ID}`, {
|
|
420
|
+
kind: "String",
|
|
421
|
+
value: "Matthijs\n",
|
|
422
|
+
}),
|
|
423
|
+
makeSetting(`AccountSignatureHtml#${PASSWORD_ACCOUNT_ID}`, {
|
|
424
|
+
kind: "String",
|
|
425
|
+
value: "<p>Matthijs</p>",
|
|
426
|
+
}),
|
|
427
|
+
makeSetting(`AccountMuted#${OAUTH_ACCOUNT_ID}`, {
|
|
428
|
+
kind: "MutedFlag",
|
|
429
|
+
value: { value: true, setAt: 1750000000000, setBy: "web-client" },
|
|
430
|
+
}),
|
|
431
|
+
makeSetting("PinnedFolders", {
|
|
432
|
+
kind: "StringList",
|
|
433
|
+
value: ["INBOX", "INBOX.Facturen"],
|
|
434
|
+
}),
|
|
435
|
+
makeSetting(`MailboxDisplayName#${LISTS_ID}`, {
|
|
436
|
+
kind: "String",
|
|
437
|
+
value: "dev/null",
|
|
438
|
+
}),
|
|
439
|
+
makeSetting(`MailboxMuted#${LISTS_ID}`, {
|
|
440
|
+
kind: "MutedFlag",
|
|
441
|
+
value: { value: true, setAt: 1756281600000 },
|
|
442
|
+
}),
|
|
443
|
+
makeSetting(composeFolderRoleAppointmentName(PASSWORD_ACCOUNT_ID, "Sent"), {
|
|
444
|
+
kind: "String",
|
|
445
|
+
value: SENT_ID,
|
|
446
|
+
}),
|
|
447
|
+
makeSetting(
|
|
448
|
+
composeFolderRoleAppointmentLabelName(PASSWORD_ACCOUNT_ID, "Sent"),
|
|
449
|
+
{ kind: "String", value: "INBOX.Sent" },
|
|
450
|
+
),
|
|
451
|
+
],
|
|
452
|
+
mailboxes: [
|
|
453
|
+
makeMailbox({
|
|
454
|
+
mailboxId: INBOX_ID,
|
|
455
|
+
accountId: PASSWORD_ACCOUNT_ID,
|
|
456
|
+
fullPath: "INBOX",
|
|
457
|
+
}),
|
|
458
|
+
makeMailbox({
|
|
459
|
+
mailboxId: SENT_ID,
|
|
460
|
+
accountId: PASSWORD_ACCOUNT_ID,
|
|
461
|
+
fullPath: "INBOX.Sent",
|
|
462
|
+
}),
|
|
463
|
+
makeMailbox({
|
|
464
|
+
mailboxId: INVOICES_ID,
|
|
465
|
+
accountId: PASSWORD_ACCOUNT_ID,
|
|
466
|
+
fullPath: "INBOX.Facturen",
|
|
467
|
+
}),
|
|
468
|
+
makeMailbox({
|
|
469
|
+
mailboxId: LISTS_ID,
|
|
470
|
+
accountId: PASSWORD_ACCOUNT_ID,
|
|
471
|
+
fullPath: "INBOX.Lists.dev-null",
|
|
472
|
+
}),
|
|
473
|
+
makeMailbox({
|
|
474
|
+
mailboxId: OAUTH_INBOX_ID,
|
|
475
|
+
accountId: OAUTH_ACCOUNT_ID,
|
|
476
|
+
fullPath: "INBOX",
|
|
477
|
+
}),
|
|
478
|
+
],
|
|
479
|
+
labels: [
|
|
480
|
+
makeLabel({ labelId: "lbl-facturen", name: "Facturen", color: "Blue" }),
|
|
481
|
+
makeLabel({ labelId: "lbl-lezen", name: "Te lezen" }),
|
|
482
|
+
],
|
|
483
|
+
filters: [
|
|
484
|
+
makeFilter({
|
|
485
|
+
filterId: "flt-invoices",
|
|
486
|
+
name: "Invoices to Facturen",
|
|
487
|
+
matchOperator: "Or",
|
|
488
|
+
literalClauses: [
|
|
489
|
+
{ field: "Subject", value: "factuur" },
|
|
490
|
+
{ field: "FromDomain", value: "moneybird.com" },
|
|
491
|
+
],
|
|
492
|
+
actionLabelId: "lbl-facturen",
|
|
493
|
+
actionMailboxId: INVOICES_ID,
|
|
494
|
+
}),
|
|
495
|
+
makeFilter({
|
|
496
|
+
filterId: "flt-semantic",
|
|
497
|
+
name: "Anything like this release note",
|
|
498
|
+
scope: "Temporary",
|
|
499
|
+
expiresAt: "2026-12-31T23:59:59+01:00",
|
|
500
|
+
hasAnchor: true,
|
|
501
|
+
}),
|
|
502
|
+
],
|
|
503
|
+
anchors: [makeAnchor({ filterId: "flt-semantic" })],
|
|
504
|
+
addresses: [
|
|
505
|
+
makeAddress({
|
|
506
|
+
addressId: "adr-bank",
|
|
507
|
+
normalizedEmail: "post@bank.example",
|
|
508
|
+
displayName: "Bank",
|
|
509
|
+
flags: {
|
|
510
|
+
vip: { value: true, setAt: 1750000000000, setBy: "web-client" },
|
|
511
|
+
wellknown: { value: true, setAt: 1750000000000 },
|
|
512
|
+
},
|
|
513
|
+
}),
|
|
514
|
+
makeAddress({
|
|
515
|
+
addressId: "adr-news",
|
|
516
|
+
normalizedEmail: "noreply@newsletter.example",
|
|
517
|
+
flags: {
|
|
518
|
+
unsubscribed: {
|
|
519
|
+
value: true,
|
|
520
|
+
setAt: 1755000000000,
|
|
521
|
+
reason: "unsubscribe link never worked",
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
],
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The same configuration with one sender flagged before `setAt` existed. Held
|
|
530
|
+
* apart from the source fixture rather than folded into it: half the tests here
|
|
531
|
+
* count what the file carries, and a legacy row is a fact about the database
|
|
532
|
+
* this instance happens to hold, not about every configuration.
|
|
533
|
+
*/
|
|
534
|
+
const sourceWithLegacyFlagsFixture = (): ConfigFixture => {
|
|
535
|
+
const fixture = sourceFixture();
|
|
536
|
+
fixture.addresses = [...fixture.addresses, makeLegacyFlaggedAddress()];
|
|
537
|
+
return fixture;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const exportSource = (fixture: ConfigFixture = sourceFixture()) =>
|
|
541
|
+
readConfigForExport(asRepositories(fixture), ACCOUNT_CONFIG_ID, IDENTITY);
|
|
542
|
+
|
|
543
|
+
const apply = (
|
|
544
|
+
store: Store,
|
|
545
|
+
document: unknown,
|
|
546
|
+
onExisting: "abort" | "merge" = "abort",
|
|
547
|
+
) =>
|
|
548
|
+
importConfig(depsOf(store, TARGET_CONFIG_ID), {
|
|
549
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
550
|
+
userId: TARGET_USER_ID,
|
|
551
|
+
document,
|
|
552
|
+
mode: "apply",
|
|
553
|
+
onExisting,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const reportOf = (outcome: Awaited<ReturnType<typeof apply>>) => {
|
|
557
|
+
assert.equal(outcome.outcome, "report");
|
|
558
|
+
if (outcome.outcome !== "report") throw new Error("unreachable");
|
|
559
|
+
return outcome.report;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/** Discovery, after the fact: the same folders under ids of this instance's own. */
|
|
563
|
+
const discover = (
|
|
564
|
+
store: Store,
|
|
565
|
+
accountId: string,
|
|
566
|
+
paths: readonly string[],
|
|
567
|
+
): void => {
|
|
568
|
+
for (const fullPath of paths) {
|
|
569
|
+
store.mailboxes.push(
|
|
570
|
+
makeMailbox({ mailboxId: nextId("mbx"), accountId, fullPath }),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
test("a configuration survives an export, an import and a discovery unchanged", async () => {
|
|
576
|
+
const document = await exportSource(sourceWithLegacyFlagsFixture());
|
|
577
|
+
const store = emptyStore();
|
|
578
|
+
|
|
579
|
+
const report = reportOf(await apply(store, document));
|
|
580
|
+
assert.equal(report.applied, true);
|
|
581
|
+
assert.equal(report.valid, true);
|
|
582
|
+
|
|
583
|
+
discover(store, PASSWORD_ACCOUNT_ID, [
|
|
584
|
+
"INBOX",
|
|
585
|
+
"INBOX.Sent",
|
|
586
|
+
"INBOX.Facturen",
|
|
587
|
+
"INBOX.Lists.dev-null",
|
|
588
|
+
]);
|
|
589
|
+
discover(store, OAUTH_ACCOUNT_ID, ["INBOX"]);
|
|
590
|
+
|
|
591
|
+
const binder = {
|
|
592
|
+
repositories: repositoriesOf(store, TARGET_CONFIG_ID),
|
|
593
|
+
appointFolderRole: appointFolderRoleInto(store),
|
|
594
|
+
now: () => NOW,
|
|
595
|
+
};
|
|
596
|
+
await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
|
|
597
|
+
await bindImportedFolders(binder, TARGET_CONFIG_ID, OAUTH_ACCOUNT_ID);
|
|
598
|
+
|
|
599
|
+
const reexported = await readConfigForExport(
|
|
600
|
+
repositoriesOf(store, TARGET_CONFIG_ID),
|
|
601
|
+
TARGET_CONFIG_ID,
|
|
602
|
+
IDENTITY,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
assert.deepEqual(withoutProvenance(reexported), withoutProvenance(document));
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Provenance records where a file came from and is never applied, and an import
|
|
610
|
+
* deliberately parks every account inactive until its credential is back. Those
|
|
611
|
+
* two aside, the document that comes back out is the document that went in.
|
|
612
|
+
*/
|
|
613
|
+
const withoutProvenance = (document: {
|
|
614
|
+
provenance: unknown;
|
|
615
|
+
accounts: { isActive: boolean }[];
|
|
616
|
+
}): unknown => ({
|
|
617
|
+
...document,
|
|
618
|
+
provenance: null,
|
|
619
|
+
accounts: document.accounts.map((account) => ({
|
|
620
|
+
...account,
|
|
621
|
+
isActive: null,
|
|
622
|
+
})),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("imported accounts land inactive, each needing the credential the file named", async () => {
|
|
626
|
+
const document = await exportSource();
|
|
627
|
+
const store = emptyStore();
|
|
628
|
+
|
|
629
|
+
const report = reportOf(await apply(store, document));
|
|
630
|
+
|
|
631
|
+
assert.deepEqual(
|
|
632
|
+
[...report.accountsNeedingCredentials].sort(),
|
|
633
|
+
[OAUTH_ACCOUNT_ID, PASSWORD_ACCOUNT_ID].sort(),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const password = store.accounts.find(
|
|
637
|
+
(account) => account.accountId === PASSWORD_ACCOUNT_ID,
|
|
638
|
+
);
|
|
639
|
+
const oauth = store.accounts.find(
|
|
640
|
+
(account) => account.accountId === OAUTH_ACCOUNT_ID,
|
|
641
|
+
);
|
|
642
|
+
assert.equal(password?.isActive, false);
|
|
643
|
+
assert.equal(password?.connectionState, "credentials_missing");
|
|
644
|
+
assert.equal(oauth?.isActive, false);
|
|
645
|
+
assert.equal(oauth?.connectionState, "reauth_required");
|
|
646
|
+
assert.equal(password?.passwordHash, undefined);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("validate writes nothing and still says what an apply would do", async () => {
|
|
650
|
+
const document = await exportSource();
|
|
651
|
+
const store = emptyStore();
|
|
652
|
+
|
|
653
|
+
const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID), {
|
|
654
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
655
|
+
userId: TARGET_USER_ID,
|
|
656
|
+
document,
|
|
657
|
+
mode: "validate",
|
|
658
|
+
onExisting: "abort",
|
|
659
|
+
});
|
|
660
|
+
const report = reportOf(outcome);
|
|
661
|
+
|
|
662
|
+
assert.equal(report.valid, true);
|
|
663
|
+
assert.equal(report.applied, false);
|
|
664
|
+
assert.equal(report.importId, undefined);
|
|
665
|
+
assert.deepEqual(
|
|
666
|
+
report.items.filter((item) => item.section === "accounts"),
|
|
667
|
+
[
|
|
668
|
+
{ section: "accounts", key: "matthijs@ischen.nl", verdict: "created" },
|
|
669
|
+
{ section: "accounts", key: "work@example.com", verdict: "created" },
|
|
670
|
+
],
|
|
671
|
+
);
|
|
672
|
+
assert.deepEqual(store.accounts, []);
|
|
673
|
+
assert.deepEqual(store.labels, []);
|
|
674
|
+
assert.deepEqual(store.imports, []);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("a configuration that already holds something refuses the default import", async () => {
|
|
678
|
+
const document = await exportSource();
|
|
679
|
+
const store = storeFromFixture(sourceFixture());
|
|
680
|
+
|
|
681
|
+
const outcome = await importConfig(depsOf(store, ACCOUNT_CONFIG_ID), {
|
|
682
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
683
|
+
userId: TARGET_USER_ID,
|
|
684
|
+
document,
|
|
685
|
+
mode: "apply",
|
|
686
|
+
onExisting: "abort",
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
assert.equal(outcome.outcome, "conflict");
|
|
690
|
+
if (outcome.outcome !== "conflict") throw new Error("unreachable");
|
|
691
|
+
assert.equal(outcome.conflict.code, "config_not_empty");
|
|
692
|
+
assert.equal(outcome.conflict.details.accounts, "2");
|
|
693
|
+
assert.equal(outcome.conflict.details.labels, "2");
|
|
694
|
+
assert.equal(outcome.conflict.details.addressFlags, "2");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("merge folds the file in and deletes nothing the file left out", async () => {
|
|
698
|
+
const document = await exportSource();
|
|
699
|
+
const store = emptyStore();
|
|
700
|
+
store.labels.push(
|
|
701
|
+
makeLabel({
|
|
702
|
+
labelId: "lbl-local",
|
|
703
|
+
name: "Facturen",
|
|
704
|
+
color: "Red",
|
|
705
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
706
|
+
}),
|
|
707
|
+
makeLabel({
|
|
708
|
+
labelId: "lbl-only-here",
|
|
709
|
+
name: "Alleen hier",
|
|
710
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
711
|
+
}),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const report = reportOf(await apply(store, document, "merge"));
|
|
715
|
+
|
|
716
|
+
assert.deepEqual(
|
|
717
|
+
report.items.filter((item) => item.section === "labels"),
|
|
718
|
+
[
|
|
719
|
+
{ section: "labels", key: "Facturen", verdict: "updated" },
|
|
720
|
+
{ section: "labels", key: "Te lezen", verdict: "created" },
|
|
721
|
+
],
|
|
722
|
+
);
|
|
723
|
+
const held = store.labels.find((label) => label.labelId === "lbl-local");
|
|
724
|
+
assert.equal(held?.color, "Blue");
|
|
725
|
+
assert.equal(
|
|
726
|
+
store.labels.some((label) => label.labelId === "lbl-only-here"),
|
|
727
|
+
true,
|
|
728
|
+
);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("a filter's label travels by name, and the imported filter points at the new id", async () => {
|
|
732
|
+
const document = await exportSource();
|
|
733
|
+
const store = emptyStore();
|
|
734
|
+
|
|
735
|
+
await apply(store, document);
|
|
736
|
+
|
|
737
|
+
const label = store.labels.find((row) => row.name === "Facturen");
|
|
738
|
+
const filter = store.filters.find(
|
|
739
|
+
(row) => row.name === "Invoices to Facturen",
|
|
740
|
+
);
|
|
741
|
+
assert.notEqual(label?.labelId, "lbl-facturen");
|
|
742
|
+
assert.equal(filter?.actionLabelId, label?.labelId);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("a flagged sender arrives ahead of its mail, keyed on the email string", async () => {
|
|
746
|
+
const document = await exportSource();
|
|
747
|
+
const store = emptyStore();
|
|
748
|
+
// A sighting the resync already harvested: the import must merge onto it
|
|
749
|
+
// rather than replace it, so the counters survive.
|
|
750
|
+
store.addresses.push(
|
|
751
|
+
makeAddress({
|
|
752
|
+
addressId: deriveAddressId(TARGET_CONFIG_ID, "post@bank.example"),
|
|
753
|
+
normalizedEmail: "post@bank.example",
|
|
754
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
755
|
+
inboundCount: 12,
|
|
756
|
+
flags: { junkOnly: { value: true, setAt: 1750000000000 } },
|
|
757
|
+
}),
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
await apply(store, document, "merge");
|
|
761
|
+
|
|
762
|
+
const bank = store.addresses.find(
|
|
763
|
+
(address) =>
|
|
764
|
+
address.addressId ===
|
|
765
|
+
deriveAddressId(TARGET_CONFIG_ID, "post@bank.example"),
|
|
766
|
+
);
|
|
767
|
+
assert.equal(bank?.inboundCount, 12);
|
|
768
|
+
assert.equal(bank?.flags?.vip?.value, true);
|
|
769
|
+
assert.equal(bank?.flags?.junkOnly?.value, true);
|
|
770
|
+
assert.equal(bank?.flags?.wellknown, undefined);
|
|
771
|
+
|
|
772
|
+
const news = store.addresses.find(
|
|
773
|
+
(address) => address.normalizedEmail === "noreply@newsletter.example",
|
|
774
|
+
);
|
|
775
|
+
assert.equal(
|
|
776
|
+
news?.addressId,
|
|
777
|
+
deriveAddressId(TARGET_CONFIG_ID, "noreply@newsletter.example"),
|
|
778
|
+
);
|
|
779
|
+
assert.equal(news?.flags?.unsubscribed?.value, true);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("a flag that arrives at the sentinel is stored at the sentinel", async () => {
|
|
783
|
+
const document = await exportSource(sourceWithLegacyFlagsFixture());
|
|
784
|
+
const store = emptyStore();
|
|
785
|
+
|
|
786
|
+
await apply(store, document);
|
|
787
|
+
|
|
788
|
+
const legacy = store.addresses.find(
|
|
789
|
+
(address) => address.normalizedEmail === "old@legacy.example",
|
|
790
|
+
);
|
|
791
|
+
assert.equal(legacy?.flags?.vip?.setAt, 0);
|
|
792
|
+
assert.equal(legacy?.flags?.category?.setAt, 0);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("a folder no mailbox answers to yet is kept, then bound when discovery finds it", async () => {
|
|
796
|
+
const document = await exportSource();
|
|
797
|
+
const store = emptyStore();
|
|
798
|
+
|
|
799
|
+
const report = reportOf(await apply(store, document));
|
|
800
|
+
assert.equal(
|
|
801
|
+
report.warnings.some((warning) => warning.code === "folder_not_found_yet"),
|
|
802
|
+
true,
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
const row = store.imports[0];
|
|
806
|
+
assert.equal(row.state, "Pending");
|
|
807
|
+
assert.equal(
|
|
808
|
+
pendingImportOf(store.imports)?.folderPaths.includes("INBOX.Facturen"),
|
|
809
|
+
true,
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Facturen"]);
|
|
813
|
+
const binder = {
|
|
814
|
+
repositories: repositoriesOf(store, TARGET_CONFIG_ID),
|
|
815
|
+
appointFolderRole: appointFolderRoleInto(store),
|
|
816
|
+
now: () => NOW,
|
|
817
|
+
};
|
|
818
|
+
const first = await bindImportedFolders(
|
|
819
|
+
binder,
|
|
820
|
+
TARGET_CONFIG_ID,
|
|
821
|
+
PASSWORD_ACCOUNT_ID,
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
assert.equal(first.bound > 0, true);
|
|
825
|
+
assert.equal(first.stillPending > 0, true);
|
|
826
|
+
const invoices = store.mailboxes.find(
|
|
827
|
+
(mailbox) => mailbox.fullPath === "INBOX.Facturen",
|
|
828
|
+
);
|
|
829
|
+
assert.equal(
|
|
830
|
+
store.filters.find((filter) => filter.name === "Invoices to Facturen")
|
|
831
|
+
?.actionMailboxId,
|
|
832
|
+
invoices?.mailboxId,
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
// Replayable: a second discovery over the same folders changes nothing.
|
|
836
|
+
const again = await bindImportedFolders(
|
|
837
|
+
binder,
|
|
838
|
+
TARGET_CONFIG_ID,
|
|
839
|
+
PASSWORD_ACCOUNT_ID,
|
|
840
|
+
);
|
|
841
|
+
assert.equal(again.bound, 0);
|
|
842
|
+
assert.equal(store.imports[0].state, "Pending");
|
|
843
|
+
|
|
844
|
+
discover(store, PASSWORD_ACCOUNT_ID, ["INBOX.Sent", "INBOX.Lists.dev-null"]);
|
|
845
|
+
discover(store, OAUTH_ACCOUNT_ID, ["INBOX"]);
|
|
846
|
+
await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
|
|
847
|
+
|
|
848
|
+
assert.equal(store.imports[0].state, "Complete");
|
|
849
|
+
assert.equal(store.imports[0].completedAt, NOW);
|
|
850
|
+
assert.equal(pendingImportOf(store.imports), undefined);
|
|
851
|
+
assert.equal(
|
|
852
|
+
store.settings.get(
|
|
853
|
+
composeFolderRoleAppointmentName(PASSWORD_ACCOUNT_ID, "Sent"),
|
|
854
|
+
)?.value.kind,
|
|
855
|
+
"String",
|
|
856
|
+
);
|
|
857
|
+
const lists = store.mailboxes.find(
|
|
858
|
+
(mailbox) => mailbox.fullPath === "INBOX.Lists.dev-null",
|
|
859
|
+
);
|
|
860
|
+
assert.equal(
|
|
861
|
+
store.settings.get(`MailboxDisplayName#${lists?.mailboxId}`)?.value.value,
|
|
862
|
+
"dev/null",
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("an import with no folders to wait for is complete the moment it lands", async () => {
|
|
867
|
+
const document = await exportSource();
|
|
868
|
+
const store = emptyStore();
|
|
869
|
+
store.accounts.push(
|
|
870
|
+
makeAccount({
|
|
871
|
+
accountId: PASSWORD_ACCOUNT_ID,
|
|
872
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
873
|
+
}),
|
|
874
|
+
makeAccount({
|
|
875
|
+
accountId: OAUTH_ACCOUNT_ID,
|
|
876
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
877
|
+
email: "work@example.com",
|
|
878
|
+
username: "work@example.com",
|
|
879
|
+
authType: "oauthMicrosoft",
|
|
880
|
+
}),
|
|
881
|
+
);
|
|
882
|
+
discover(store, PASSWORD_ACCOUNT_ID, [
|
|
883
|
+
"INBOX",
|
|
884
|
+
"INBOX.Sent",
|
|
885
|
+
"INBOX.Facturen",
|
|
886
|
+
"INBOX.Lists.dev-null",
|
|
887
|
+
]);
|
|
888
|
+
discover(store, OAUTH_ACCOUNT_ID, ["INBOX"]);
|
|
889
|
+
|
|
890
|
+
const report = reportOf(await apply(store, document, "merge"));
|
|
891
|
+
|
|
892
|
+
assert.deepEqual(report.warnings, []);
|
|
893
|
+
assert.equal(store.imports[0].state, "Complete");
|
|
894
|
+
assert.equal(
|
|
895
|
+
report.items.filter((item) => item.section === "accounts")[0]?.verdict,
|
|
896
|
+
"updated",
|
|
897
|
+
);
|
|
898
|
+
// A merge onto a live account leaves the credential it already has alone.
|
|
899
|
+
assert.equal(
|
|
900
|
+
store.accounts.find((account) => account.accountId === PASSWORD_ACCOUNT_ID)
|
|
901
|
+
?.isActive,
|
|
902
|
+
true,
|
|
903
|
+
);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("a file carrying a credential is refused, not stripped", async () => {
|
|
907
|
+
const document = (await exportSource()) as unknown as {
|
|
908
|
+
accounts: Record<string, unknown>[];
|
|
909
|
+
};
|
|
910
|
+
document.accounts[0].password = "hunter2";
|
|
911
|
+
const store = emptyStore();
|
|
912
|
+
|
|
913
|
+
const report = reportOf(await apply(store, document));
|
|
914
|
+
|
|
915
|
+
assert.equal(report.valid, false);
|
|
916
|
+
assert.equal(report.applied, false);
|
|
917
|
+
assert.equal(report.errors[0]?.code, "CredentialPresent");
|
|
918
|
+
assert.match(report.errors[0]?.message ?? "", /never carries one/);
|
|
919
|
+
assert.deepEqual(store.accounts, []);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("a document written by a newer reader is refused with its own version named", async () => {
|
|
923
|
+
const store = emptyStore();
|
|
924
|
+
const report = reportOf(
|
|
925
|
+
await apply(store, {
|
|
926
|
+
kind: "reader.config",
|
|
927
|
+
schemaVersion: 99,
|
|
928
|
+
generator: {
|
|
929
|
+
app: "reader",
|
|
930
|
+
version: "v9",
|
|
931
|
+
exportedAt: IDENTITY.exportedAt,
|
|
932
|
+
},
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
assert.equal(report.valid, false);
|
|
937
|
+
assert.equal(report.schemaVersion, 99);
|
|
938
|
+
assert.equal(report.errors[0]?.code, "UnsupportedVersion");
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("a document that is not a reader configuration is refused", async () => {
|
|
942
|
+
const store = emptyStore();
|
|
943
|
+
const report = reportOf(await apply(store, "not a document"));
|
|
944
|
+
|
|
945
|
+
assert.equal(report.valid, false);
|
|
946
|
+
assert.equal(report.schemaVersion, 0);
|
|
947
|
+
assert.equal(report.errors[0]?.code, "NotAnObject");
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test("a document naming the same label twice is refused before anything is written", async () => {
|
|
951
|
+
const document = (await exportSource()) as unknown as {
|
|
952
|
+
labels: { name: string; color: string }[];
|
|
953
|
+
};
|
|
954
|
+
document.labels.push({ name: "facturen", color: "Green" });
|
|
955
|
+
const store = emptyStore();
|
|
956
|
+
|
|
957
|
+
const report = reportOf(await apply(store, document));
|
|
958
|
+
|
|
959
|
+
assert.equal(report.valid, false);
|
|
960
|
+
assert.equal(report.errors[0]?.code, "duplicate_key");
|
|
961
|
+
assert.deepEqual(store.labels, []);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test("a filter pointing at a label no one has is refused", async () => {
|
|
965
|
+
const document = (await exportSource()) as unknown as {
|
|
966
|
+
labels: unknown[];
|
|
967
|
+
filters: { actionLabelName: string | null }[];
|
|
968
|
+
};
|
|
969
|
+
document.labels = [];
|
|
970
|
+
const store = emptyStore();
|
|
971
|
+
|
|
972
|
+
const report = reportOf(await apply(store, document));
|
|
973
|
+
|
|
974
|
+
assert.equal(report.valid, false);
|
|
975
|
+
assert.deepEqual(
|
|
976
|
+
report.errors.map((error) => error.code),
|
|
977
|
+
["unknown_label"],
|
|
978
|
+
);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("a filter filing into an account the file does not carry is refused", async () => {
|
|
982
|
+
const document = (await exportSource()) as unknown as {
|
|
983
|
+
filters: {
|
|
984
|
+
actionFolder: { accountId: string; folderPath: string } | null;
|
|
985
|
+
}[];
|
|
986
|
+
};
|
|
987
|
+
document.filters[0].actionFolder = {
|
|
988
|
+
accountId: "zzzzzzzzzzzzzzzzzzzzzzzzz",
|
|
989
|
+
folderPath: "INBOX",
|
|
990
|
+
};
|
|
991
|
+
const store = emptyStore();
|
|
992
|
+
|
|
993
|
+
const report = reportOf(await apply(store, document));
|
|
994
|
+
|
|
995
|
+
assert.equal(report.valid, false);
|
|
996
|
+
assert.equal(report.errors[0]?.code, "unknown_account");
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
test("an anchor this deployment cannot embed is stored as text for the repair to pick up", async () => {
|
|
1000
|
+
const document = await exportSource();
|
|
1001
|
+
const store = emptyStore();
|
|
1002
|
+
|
|
1003
|
+
const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID, false), {
|
|
1004
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
1005
|
+
userId: TARGET_USER_ID,
|
|
1006
|
+
document,
|
|
1007
|
+
mode: "apply",
|
|
1008
|
+
onExisting: "abort",
|
|
1009
|
+
});
|
|
1010
|
+
const report = reportOf(outcome);
|
|
1011
|
+
|
|
1012
|
+
const anchor = store.anchors[0];
|
|
1013
|
+
assert.equal(anchor.anchorEmbeddingId, ANCHOR_EMBEDDING_PENDING);
|
|
1014
|
+
assert.deepEqual(anchor.anchorEmbedding, []);
|
|
1015
|
+
assert.equal(
|
|
1016
|
+
anchor.anchorSourceText,
|
|
1017
|
+
"the release note this filter was drawn from",
|
|
1018
|
+
);
|
|
1019
|
+
assert.equal(
|
|
1020
|
+
report.warnings.some((warning) => warning.code === "anchor_not_embedded"),
|
|
1021
|
+
true,
|
|
1022
|
+
);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("an embedder that fails costs the anchor its vector, not the import", async () => {
|
|
1026
|
+
const document = await exportSource();
|
|
1027
|
+
const store = emptyStore();
|
|
1028
|
+
const deps = depsOf(store, TARGET_CONFIG_ID);
|
|
1029
|
+
|
|
1030
|
+
const report = reportOf(
|
|
1031
|
+
await importConfig(
|
|
1032
|
+
{
|
|
1033
|
+
...deps,
|
|
1034
|
+
embedAnchor: () => Promise.reject(new Error("embedder unavailable")),
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
1038
|
+
userId: TARGET_USER_ID,
|
|
1039
|
+
document,
|
|
1040
|
+
mode: "apply",
|
|
1041
|
+
onExisting: "abort",
|
|
1042
|
+
},
|
|
1043
|
+
),
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
assert.equal(report.applied, true);
|
|
1047
|
+
assert.deepEqual(report.errors, []);
|
|
1048
|
+
assert.equal(
|
|
1049
|
+
report.warnings.some((warning) => warning.code === "anchor_not_embedded"),
|
|
1050
|
+
true,
|
|
1051
|
+
);
|
|
1052
|
+
assert.equal(store.anchors[0]?.anchorEmbeddingId, ANCHOR_EMBEDDING_PENDING);
|
|
1053
|
+
assert.equal(
|
|
1054
|
+
store.anchors[0]?.anchorSourceText,
|
|
1055
|
+
"the release note this filter was drawn from",
|
|
1056
|
+
);
|
|
1057
|
+
assert.equal(store.accounts.length > 0, true);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test("the dry run names the same folders and credentials the apply does", async () => {
|
|
1061
|
+
const document = await exportSource();
|
|
1062
|
+
const planStore = emptyStore();
|
|
1063
|
+
|
|
1064
|
+
const planned = reportOf(
|
|
1065
|
+
await importConfig(depsOf(planStore, TARGET_CONFIG_ID), {
|
|
1066
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
1067
|
+
userId: TARGET_USER_ID,
|
|
1068
|
+
document,
|
|
1069
|
+
mode: "validate",
|
|
1070
|
+
onExisting: "abort",
|
|
1071
|
+
}),
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const applied = reportOf(await apply(emptyStore(), document));
|
|
1075
|
+
|
|
1076
|
+
const codes = (report: { warnings: { code: string }[] }) =>
|
|
1077
|
+
report.warnings.map((warning) => warning.code).sort();
|
|
1078
|
+
|
|
1079
|
+
assert.equal(planned.warnings.length > 0, true);
|
|
1080
|
+
assert.deepEqual(codes(planned), codes(applied));
|
|
1081
|
+
assert.deepEqual(
|
|
1082
|
+
[...planned.accountsNeedingCredentials].sort(),
|
|
1083
|
+
[...applied.accountsNeedingCredentials].sort(),
|
|
1084
|
+
);
|
|
1085
|
+
assert.deepEqual(planStore.accounts, []);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test("a write that fails after validation leaves nothing behind and names where it stopped", async () => {
|
|
1089
|
+
const document = await exportSource();
|
|
1090
|
+
const store = emptyStore();
|
|
1091
|
+
store.failOn = "filter";
|
|
1092
|
+
|
|
1093
|
+
const report = reportOf(await apply(store, document));
|
|
1094
|
+
|
|
1095
|
+
assert.equal(report.valid, true);
|
|
1096
|
+
assert.equal(report.applied, false);
|
|
1097
|
+
assert.equal(report.errors[0]?.code, "import_write_failed");
|
|
1098
|
+
assert.equal(report.errors[0]?.details?.section, "filters");
|
|
1099
|
+
assert.match(report.errors[0]?.message ?? "", /Nothing was written/);
|
|
1100
|
+
assert.equal(report.items[0]?.verdict, "rejected");
|
|
1101
|
+
|
|
1102
|
+
// Filters fail after accounts and labels have been written, so these are the
|
|
1103
|
+
// assertions that make the claim: the rollback undid them too.
|
|
1104
|
+
assert.deepEqual(store.imports, []);
|
|
1105
|
+
assert.deepEqual(store.accounts, []);
|
|
1106
|
+
assert.deepEqual(store.labels, []);
|
|
1107
|
+
assert.deepEqual(store.filters, []);
|
|
1108
|
+
assert.equal(store.accountConfigs.size, 0);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test("a write that fails without a transaction reports what it did leave behind", async () => {
|
|
1112
|
+
const document = await exportSource();
|
|
1113
|
+
const store = emptyStore();
|
|
1114
|
+
store.failOn = "filter";
|
|
1115
|
+
|
|
1116
|
+
const deps = depsOf(store, TARGET_CONFIG_ID);
|
|
1117
|
+
const report = reportOf(
|
|
1118
|
+
await importConfig(
|
|
1119
|
+
{ ...deps, transaction: undefined },
|
|
1120
|
+
{
|
|
1121
|
+
accountConfigId: TARGET_CONFIG_ID,
|
|
1122
|
+
userId: TARGET_USER_ID,
|
|
1123
|
+
document,
|
|
1124
|
+
mode: "apply",
|
|
1125
|
+
onExisting: "abort",
|
|
1126
|
+
},
|
|
1127
|
+
),
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
assert.equal(report.applied, true);
|
|
1131
|
+
assert.match(report.errors[0]?.message ?? "", /were written and remain/);
|
|
1132
|
+
assert.equal(
|
|
1133
|
+
report.items.some(
|
|
1134
|
+
(item) => item.section === "accounts" && item.verdict === "created",
|
|
1135
|
+
),
|
|
1136
|
+
true,
|
|
1137
|
+
);
|
|
1138
|
+
assert.equal(store.accounts.length > 0, true);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test("the configuration row is created when the database holds none, and named from the file", async () => {
|
|
1142
|
+
const document = await exportSource();
|
|
1143
|
+
const store = emptyStore();
|
|
1144
|
+
|
|
1145
|
+
await apply(store, document);
|
|
1146
|
+
|
|
1147
|
+
assert.equal(store.accountConfigs.get(TARGET_CONFIG_ID)?.name, "Matthijs");
|
|
1148
|
+
assert.equal(
|
|
1149
|
+
store.accountConfigs.get(TARGET_CONFIG_ID)?.userId,
|
|
1150
|
+
TARGET_USER_ID,
|
|
1151
|
+
);
|
|
1152
|
+
assert.equal(
|
|
1153
|
+
store.settings.get("DefaultComposerFormat")?.value.value,
|
|
1154
|
+
"markdown",
|
|
1155
|
+
);
|
|
1156
|
+
assert.deepEqual(store.settings.get("PinnedFolders")?.value.value, [
|
|
1157
|
+
"INBOX",
|
|
1158
|
+
"INBOX.Facturen",
|
|
1159
|
+
]);
|
|
1160
|
+
assert.equal(
|
|
1161
|
+
store.settings.get(`AccountSignatureHtml#${PASSWORD_ACCOUNT_ID}`)?.value
|
|
1162
|
+
.value,
|
|
1163
|
+
"<p>Matthijs</p>",
|
|
1164
|
+
);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test("a second import of the same file over the same configuration changes nothing new", async () => {
|
|
1168
|
+
const document = await exportSource();
|
|
1169
|
+
const store = emptyStore();
|
|
1170
|
+
|
|
1171
|
+
await apply(store, document);
|
|
1172
|
+
const accounts = store.accounts.length;
|
|
1173
|
+
const labels = store.labels.length;
|
|
1174
|
+
const filters = store.filters.length;
|
|
1175
|
+
|
|
1176
|
+
const report = reportOf(await apply(store, document, "merge"));
|
|
1177
|
+
|
|
1178
|
+
assert.equal(store.accounts.length, accounts);
|
|
1179
|
+
assert.equal(store.labels.length, labels);
|
|
1180
|
+
assert.equal(store.filters.length, filters);
|
|
1181
|
+
assert.equal(
|
|
1182
|
+
report.items.every((item) => item.verdict === "updated"),
|
|
1183
|
+
true,
|
|
1184
|
+
);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
test("a binder run on a configuration with no import does nothing", async () => {
|
|
1188
|
+
const store = emptyStore();
|
|
1189
|
+
const result = await bindImportedFolders(
|
|
1190
|
+
{
|
|
1191
|
+
repositories: repositoriesOf(store, TARGET_CONFIG_ID),
|
|
1192
|
+
appointFolderRole: appointFolderRoleInto(store),
|
|
1193
|
+
now: () => NOW,
|
|
1194
|
+
},
|
|
1195
|
+
TARGET_CONFIG_ID,
|
|
1196
|
+
PASSWORD_ACCOUNT_ID,
|
|
1197
|
+
);
|
|
1198
|
+
assert.deepEqual(result, { bound: 0, stillPending: 0 });
|
|
1199
|
+
assert.equal(pendingImportOf([]), undefined);
|
|
1200
|
+
});
|