@remit/config-transfer 0.0.3 → 0.0.5
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/export.test.ts +39 -2
- package/src/export.ts +1 -1
- package/src/fixtures.ts +76 -13
- package/src/import-repositories.ts +6 -1
- package/src/import.test.ts +33 -0
- package/src/import.ts +4 -1
- package/src/repositories.ts +7 -1
package/package.json
CHANGED
package/src/export.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { ConfigExportRefusedError, readConfigForExport } from "./export.js";
|
|
11
11
|
import {
|
|
12
12
|
ACCOUNT_CONFIG_ID,
|
|
13
|
+
asAddressRepository,
|
|
13
14
|
asRepositories,
|
|
14
15
|
type ConfigFixture,
|
|
15
16
|
makeAccount,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
makeAddress,
|
|
18
19
|
makeAnchor,
|
|
19
20
|
makeFilter,
|
|
21
|
+
makeJunkOnlyFlaggedAddress,
|
|
20
22
|
makeLabel,
|
|
21
23
|
makeLegacyFlaggedAddress,
|
|
22
24
|
makeMailbox,
|
|
@@ -463,13 +465,48 @@ test("an address exports only when the user decided something about it", async (
|
|
|
463
465
|
},
|
|
464
466
|
}),
|
|
465
467
|
];
|
|
466
|
-
fixture.addresses = [
|
|
468
|
+
fixture.addresses = [
|
|
469
|
+
...derivedOnly,
|
|
470
|
+
...fixture.addresses,
|
|
471
|
+
makeJunkOnlyFlaggedAddress(),
|
|
472
|
+
];
|
|
467
473
|
|
|
468
474
|
const document = await exportFixture(fixture);
|
|
469
475
|
|
|
470
476
|
assert.deepEqual(
|
|
471
477
|
document.addressFlags.map((entry) => entry.normalizedEmail),
|
|
472
|
-
["post@bank.example", "noreply@newsletter.example"],
|
|
478
|
+
["post@bank.example", "sale@junk.example", "noreply@newsletter.example"],
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("an address the machine marked junk-only still exports the decision on it", async () => {
|
|
483
|
+
const fixture = fullFixture();
|
|
484
|
+
fixture.addresses = [...fixture.addresses, makeJunkOnlyFlaggedAddress()];
|
|
485
|
+
|
|
486
|
+
const suggested = await asAddressRepository(fixture).listByAccountConfig({
|
|
487
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
488
|
+
});
|
|
489
|
+
assert.equal(
|
|
490
|
+
suggested.items.some((item) => item.addressId === "adr-junk-news"),
|
|
491
|
+
false,
|
|
492
|
+
"the suggest listing is right to hide it, and is not the export's read",
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const document = await exportFixture(fixture);
|
|
496
|
+
|
|
497
|
+
const exported = document.addressFlags.find(
|
|
498
|
+
(entry) => entry.normalizedEmail === "sale@junk.example",
|
|
499
|
+
);
|
|
500
|
+
assert.deepEqual(exported?.flags.unsubscribed, {
|
|
501
|
+
value: true,
|
|
502
|
+
setAt: 1755000000000,
|
|
503
|
+
});
|
|
504
|
+
assert.equal(exported?.flags.autoArchive?.value, true);
|
|
505
|
+
assert.equal(exported?.flags.category?.value, "newsletter");
|
|
506
|
+
assert.equal(
|
|
507
|
+
"junkOnly" in (exported?.flags ?? {}),
|
|
508
|
+
false,
|
|
509
|
+
"the machine's own flag is still not carried",
|
|
473
510
|
);
|
|
474
511
|
});
|
|
475
512
|
|
package/src/export.ts
CHANGED
|
@@ -329,7 +329,7 @@ const readAddresses = async (
|
|
|
329
329
|
const addresses: AddressItem[] = [];
|
|
330
330
|
let cursor: string | undefined;
|
|
331
331
|
do {
|
|
332
|
-
const page = await repositories.address.
|
|
332
|
+
const page = await repositories.address.listAllByAccountConfigPage({
|
|
333
333
|
accountConfigId,
|
|
334
334
|
cursor,
|
|
335
335
|
});
|
package/src/fixtures.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AddressItem,
|
|
6
6
|
FilterAnchorItem,
|
|
7
7
|
FilterItem,
|
|
8
|
+
IAddressRepository,
|
|
8
9
|
LabelItem,
|
|
9
10
|
MailboxItem,
|
|
10
11
|
} from "@remit/data-ports";
|
|
@@ -89,6 +90,7 @@ export const makeMailbox = (
|
|
|
89
90
|
highWaterMarkUid: 0,
|
|
90
91
|
lastMessageSyncAt: NOW,
|
|
91
92
|
parentMailboxId: "",
|
|
93
|
+
syncStatus: "synced",
|
|
92
94
|
cursorState: "normal",
|
|
93
95
|
createdAt: NOW,
|
|
94
96
|
updatedAt: NOW,
|
|
@@ -185,6 +187,79 @@ export const makeLegacyFlaggedAddress = (): AddressItem =>
|
|
|
185
187
|
} as unknown as AddressItem["flags"],
|
|
186
188
|
});
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The shape #1029 was about: mail the reader unsubscribed from and never
|
|
192
|
+
* replied to, landing only in Junk, so sync marks the row `junkOnly`. The
|
|
193
|
+
* autocomplete listing is right to hide it; neither half of the transfer is.
|
|
194
|
+
*/
|
|
195
|
+
export const makeJunkOnlyFlaggedAddress = (): AddressItem =>
|
|
196
|
+
makeAddress({
|
|
197
|
+
addressId: "adr-junk-news",
|
|
198
|
+
normalizedEmail: "sale@junk.example",
|
|
199
|
+
flags: {
|
|
200
|
+
junkOnly: { value: true, setAt: 1750000000000 },
|
|
201
|
+
unsubscribed: { value: true, setAt: 1755000000000 },
|
|
202
|
+
autoArchive: { value: true, setAt: 1755000000000 },
|
|
203
|
+
category: { value: "newsletter", setAt: 1755000000000 },
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The suggest predicate the stored listing applies, spelled in JavaScript: an
|
|
209
|
+
* address is hidden exactly when the machine marked it `junkOnly` and the
|
|
210
|
+
* account has neither corresponded with the sender nor flagged it. Modelled
|
|
211
|
+
* rather than ignored, because a stub that filters nothing cannot tell a read
|
|
212
|
+
* that hides decisions apart from one that does (#1029).
|
|
213
|
+
*/
|
|
214
|
+
export const suggestable = (address: AddressItem): boolean => {
|
|
215
|
+
const flags = address.flags ?? {};
|
|
216
|
+
if (flags.junkOnly?.value !== true) return true;
|
|
217
|
+
const corresponded =
|
|
218
|
+
address.outboundCount + address.replyCount > 0 ||
|
|
219
|
+
flags.vip?.value === true ||
|
|
220
|
+
flags.trusted?.value === true;
|
|
221
|
+
return (
|
|
222
|
+
corresponded || flags.blocked?.value === true || flags.muted?.value === true
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const byAddressId = (rows: AddressItem[]): AddressItem[] =>
|
|
227
|
+
[...rows].sort((left, right) =>
|
|
228
|
+
left.addressId < right.addressId
|
|
229
|
+
? -1
|
|
230
|
+
: left.addressId > right.addressId
|
|
231
|
+
? 1
|
|
232
|
+
: 0,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Both address reads, so a test can hold them against each other: the suggest
|
|
237
|
+
* listing hides a junk-only row, the export's listing carries every row in
|
|
238
|
+
* `addressId` order.
|
|
239
|
+
*/
|
|
240
|
+
export const asAddressRepository = (
|
|
241
|
+
fixture: ConfigFixture,
|
|
242
|
+
): Pick<
|
|
243
|
+
IAddressRepository,
|
|
244
|
+
"listByAccountConfig" | "listAllByAccountConfigPage"
|
|
245
|
+
> => {
|
|
246
|
+
const pageOf = (rows: AddressItem[], cursor: string | undefined) => {
|
|
247
|
+
const size = fixture.addressPageSize ?? rows.length + 1;
|
|
248
|
+
const offset = cursor ? Number(cursor) : 0;
|
|
249
|
+
const next = offset + size;
|
|
250
|
+
return {
|
|
251
|
+
items: rows.slice(offset, next),
|
|
252
|
+
continuationToken: next < rows.length ? String(next) : undefined,
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
listByAccountConfig: async ({ cursor }) =>
|
|
257
|
+
pageOf(fixture.addresses.filter(suggestable), cursor),
|
|
258
|
+
listAllByAccountConfigPage: async ({ cursor }) =>
|
|
259
|
+
pageOf(byAddressId(fixture.addresses), cursor),
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
188
263
|
/** The fixture, behind the repository interfaces the export takes. */
|
|
189
264
|
export const asRepositories = (
|
|
190
265
|
fixture: ConfigFixture,
|
|
@@ -211,17 +286,5 @@ export const asRepositories = (
|
|
|
211
286
|
filterAnchor: {
|
|
212
287
|
listByAccountConfig: async () => fixture.anchors,
|
|
213
288
|
},
|
|
214
|
-
address:
|
|
215
|
-
listByAccountConfig: async ({ cursor }) => {
|
|
216
|
-
const size = fixture.addressPageSize ?? fixture.addresses.length + 1;
|
|
217
|
-
const offset = cursor ? Number(cursor) : 0;
|
|
218
|
-
const items = fixture.addresses.slice(offset, offset + size);
|
|
219
|
-
const next = offset + size;
|
|
220
|
-
return {
|
|
221
|
-
items,
|
|
222
|
-
continuationToken:
|
|
223
|
-
next < fixture.addresses.length ? String(next) : undefined,
|
|
224
|
-
};
|
|
225
|
-
},
|
|
226
|
-
},
|
|
289
|
+
address: asAddressRepository(fixture),
|
|
227
290
|
});
|
|
@@ -39,9 +39,14 @@ export interface ConfigImportRepositories {
|
|
|
39
39
|
label: Pick<ILabelRepository, "listByAccountConfig" | "create" | "update">;
|
|
40
40
|
filter: Pick<IFilterRepository, "listByAccountConfig" | "create" | "update">;
|
|
41
41
|
filterAnchor: Pick<IFilterAnchorRepository, "put">;
|
|
42
|
+
/**
|
|
43
|
+
* Not the suggest listing, for the reason the export refuses it too: what a
|
|
44
|
+
* configuration already holds is decided over every stored row, junk-only
|
|
45
|
+
* ones included (#1029).
|
|
46
|
+
*/
|
|
42
47
|
address: Pick<
|
|
43
48
|
IAddressRepository,
|
|
44
|
-
"
|
|
49
|
+
"listAllByAccountConfigPage" | "upsertAddress" | "mergeFlags"
|
|
45
50
|
>;
|
|
46
51
|
configImport: Pick<
|
|
47
52
|
IConfigImportRepository,
|
package/src/import.test.ts
CHANGED
|
@@ -28,12 +28,14 @@ import {
|
|
|
28
28
|
makeAddress,
|
|
29
29
|
makeAnchor,
|
|
30
30
|
makeFilter,
|
|
31
|
+
makeJunkOnlyFlaggedAddress,
|
|
31
32
|
makeLabel,
|
|
32
33
|
makeLegacyFlaggedAddress,
|
|
33
34
|
makeMailbox,
|
|
34
35
|
makeSetting,
|
|
35
36
|
OAUTH_ACCOUNT_ID,
|
|
36
37
|
PASSWORD_ACCOUNT_ID,
|
|
38
|
+
suggestable,
|
|
37
39
|
} from "./fixtures.js";
|
|
38
40
|
import { ANCHOR_EMBEDDING_PENDING, importConfig } from "./import.js";
|
|
39
41
|
import type { ConfigImportDeps } from "./import-repositories.js";
|
|
@@ -246,7 +248,13 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
|
|
|
246
248
|
listByAccountConfig: async () => [...store.anchors],
|
|
247
249
|
},
|
|
248
250
|
address: {
|
|
251
|
+
// Both reads, so the stub can tell them apart: the suggest listing
|
|
252
|
+
// hides a junk-only row, and the import must not be reading it (#1029).
|
|
249
253
|
listByAccountConfig: async () => ({
|
|
254
|
+
items: store.addresses.filter(suggestable),
|
|
255
|
+
continuationToken: undefined,
|
|
256
|
+
}),
|
|
257
|
+
listAllByAccountConfigPage: async () => ({
|
|
250
258
|
items: [...store.addresses],
|
|
251
259
|
continuationToken: undefined,
|
|
252
260
|
}),
|
|
@@ -694,6 +702,31 @@ test("a configuration that already holds something refuses the default import",
|
|
|
694
702
|
assert.equal(outcome.conflict.details.addressFlags, "2");
|
|
695
703
|
});
|
|
696
704
|
|
|
705
|
+
test("a junk-only sender the user decided about still holds the configuration", async () => {
|
|
706
|
+
const document = await exportSource();
|
|
707
|
+
const store = emptyStore();
|
|
708
|
+
store.addresses.push(makeJunkOnlyFlaggedAddress());
|
|
709
|
+
|
|
710
|
+
assert.equal(
|
|
711
|
+
store.addresses.filter(suggestable).length,
|
|
712
|
+
0,
|
|
713
|
+
"the suggest listing hides it, and is not the read the import may use",
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const outcome = await importConfig(depsOf(store, ACCOUNT_CONFIG_ID), {
|
|
717
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
718
|
+
userId: TARGET_USER_ID,
|
|
719
|
+
document,
|
|
720
|
+
mode: "apply",
|
|
721
|
+
onExisting: "abort",
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
assert.equal(outcome.outcome, "conflict");
|
|
725
|
+
if (outcome.outcome !== "conflict") throw new Error("unreachable");
|
|
726
|
+
assert.equal(outcome.conflict.code, "config_not_empty");
|
|
727
|
+
assert.equal(outcome.conflict.details.addressFlags, "1");
|
|
728
|
+
});
|
|
729
|
+
|
|
697
730
|
test("merge folds the file in and deletes nothing the file left out", async () => {
|
|
698
731
|
const document = await exportSource();
|
|
699
732
|
const store = emptyStore();
|
package/src/import.ts
CHANGED
|
@@ -118,11 +118,14 @@ const readExisting = async (
|
|
|
118
118
|
]);
|
|
119
119
|
const live = accounts.filter((account) => account.deletedAt === undefined);
|
|
120
120
|
|
|
121
|
+
// Not the suggest listing: it hides an address the machine marked `junkOnly`,
|
|
122
|
+
// so a decision the user made about a junk-only sender would read as absent
|
|
123
|
+
// and be re-created or left out of the conflict count (#1029).
|
|
121
124
|
let flaggedAddresses = 0;
|
|
122
125
|
const addressEmails = new Set<string>();
|
|
123
126
|
let cursor: string | undefined;
|
|
124
127
|
do {
|
|
125
|
-
const page = await repositories.address.
|
|
128
|
+
const page = await repositories.address.listAllByAccountConfigPage({
|
|
126
129
|
accountConfigId,
|
|
127
130
|
cursor,
|
|
128
131
|
});
|
package/src/repositories.ts
CHANGED
|
@@ -25,7 +25,13 @@ export interface ConfigExportRepositories {
|
|
|
25
25
|
label: Pick<ILabelRepository, "listByAccountConfig">;
|
|
26
26
|
filter: Pick<IFilterRepository, "listByAccountConfig">;
|
|
27
27
|
filterAnchor: Pick<IFilterAnchorRepository, "listByAccountConfig">;
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Not the suggest listing: that one hides an address the machine marked
|
|
30
|
+
* `junkOnly` unless the account has corresponded with it, which is right for
|
|
31
|
+
* autocomplete and wrong for a file whose whole job is to keep the reader's
|
|
32
|
+
* decisions (#1029).
|
|
33
|
+
*/
|
|
34
|
+
address: Pick<IAddressRepository, "listAllByAccountConfigPage">;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** Who wrote the file, and where from. Recorded; never authoritative on import. */
|