@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/config-transfer",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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 = [...derivedOnly, ...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.listByAccountConfig({
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
- "listByAccountConfig" | "upsertAddress" | "mergeFlags"
49
+ "listAllByAccountConfigPage" | "upsertAddress" | "mergeFlags"
45
50
  >;
46
51
  configImport: Pick<
47
52
  IConfigImportRepository,
@@ -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.listByAccountConfig({
128
+ const page = await repositories.address.listAllByAccountConfigPage({
126
129
  accountConfigId,
127
130
  cursor,
128
131
  });
@@ -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
- address: Pick<IAddressRepository, "listByAccountConfig">;
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. */