@remit/backend 0.0.109 → 0.0.111
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,14 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AddressResponse,
|
|
5
|
+
UpdateAddressInput,
|
|
6
|
+
} from "@remit/api-openapi-types";
|
|
7
|
+
import type {
|
|
8
|
+
AddressItem,
|
|
9
|
+
FlagsMergePatch,
|
|
10
|
+
ResultList,
|
|
11
|
+
} from "@remit/data-ports";
|
|
4
12
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
5
13
|
import type { Context } from "openapi-backend";
|
|
6
14
|
import { deriveAccountConfigId } from "../auth.js";
|
|
@@ -9,7 +17,7 @@ import {
|
|
|
9
17
|
type RemitClient,
|
|
10
18
|
setClient,
|
|
11
19
|
} from "../service/data-client.js";
|
|
12
|
-
import { AddressOperations } from "./address.js";
|
|
20
|
+
import { AddressDetailOperations, AddressOperations } from "./address.js";
|
|
13
21
|
|
|
14
22
|
const searchAddresses =
|
|
15
23
|
AddressOperations.AddressOperations_searchAddresses as unknown as (
|
|
@@ -17,6 +25,12 @@ const searchAddresses =
|
|
|
17
25
|
event: APIGatewayProxyEvent,
|
|
18
26
|
) => Promise<ResultList<AddressItem>>;
|
|
19
27
|
|
|
28
|
+
const updateAddress =
|
|
29
|
+
AddressDetailOperations.AddressDetailOperations_updateAddress as unknown as (
|
|
30
|
+
context: Context,
|
|
31
|
+
event: APIGatewayProxyEvent,
|
|
32
|
+
) => Promise<AddressResponse>;
|
|
33
|
+
|
|
20
34
|
const SUB = "cognito-sub-704";
|
|
21
35
|
const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
|
|
22
36
|
|
|
@@ -130,3 +144,102 @@ describe("AddressOperations_searchAddresses", () => {
|
|
|
130
144
|
assert.equal(seen[0].limit, 10);
|
|
131
145
|
});
|
|
132
146
|
});
|
|
147
|
+
|
|
148
|
+
const updateContextFor = (body: UpdateAddressInput): Context =>
|
|
149
|
+
({
|
|
150
|
+
request: { params: { addressId: "addr-1" }, requestBody: body },
|
|
151
|
+
}) as unknown as Context;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fake data client that applies the same merge semantics the repo does:
|
|
155
|
+
* a `null` in the patch deletes the key, anything else writes it.
|
|
156
|
+
*/
|
|
157
|
+
const clientHolding = (
|
|
158
|
+
stored: AddressItem,
|
|
159
|
+
seen: FlagsMergePatch[],
|
|
160
|
+
): RemitClient =>
|
|
161
|
+
({
|
|
162
|
+
address: {
|
|
163
|
+
getAddress: async (): Promise<AddressItem> => stored,
|
|
164
|
+
mergeFlags: async (
|
|
165
|
+
_accountConfigId: string,
|
|
166
|
+
_addressId: string,
|
|
167
|
+
patch: FlagsMergePatch,
|
|
168
|
+
): Promise<AddressItem> => {
|
|
169
|
+
seen.push(patch);
|
|
170
|
+
const flags: Record<string, unknown> = { ...(stored.flags ?? {}) };
|
|
171
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
172
|
+
if (value === undefined) continue;
|
|
173
|
+
if (value === null) delete flags[key];
|
|
174
|
+
else flags[key] = value;
|
|
175
|
+
}
|
|
176
|
+
return { ...stored, flags } as AddressItem;
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}) as unknown as RemitClient;
|
|
180
|
+
|
|
181
|
+
describe("AddressDetailOperations_updateAddress removing a flag (#615)", () => {
|
|
182
|
+
it("clears the category override, which has no false-equivalent value to send", async () => {
|
|
183
|
+
const seen: FlagsMergePatch[] = [];
|
|
184
|
+
setClient(
|
|
185
|
+
clientHolding(
|
|
186
|
+
address({
|
|
187
|
+
flags: { category: { value: "newsletter", setAt: 10 } },
|
|
188
|
+
}),
|
|
189
|
+
seen,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const response = await updateAddress(
|
|
194
|
+
updateContextFor({ clearFlags: ["category"] }),
|
|
195
|
+
eventFor(SUB),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert.deepEqual(seen, [{ category: null }]);
|
|
199
|
+
assert.equal(response.flags.category, undefined);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("clears a boolean flag by the same route, leaving the untouched ones alone", async () => {
|
|
203
|
+
const seen: FlagsMergePatch[] = [];
|
|
204
|
+
setClient(
|
|
205
|
+
clientHolding(
|
|
206
|
+
address({
|
|
207
|
+
flags: {
|
|
208
|
+
muted: { value: true, setAt: 10 },
|
|
209
|
+
vip: { value: true, setAt: 11 },
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
seen,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const response = await updateAddress(
|
|
217
|
+
updateContextFor({ clearFlags: ["muted"] }),
|
|
218
|
+
eventFor(SUB),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(response.flags.muted, undefined);
|
|
222
|
+
assert.deepEqual(response.flags.vip, { value: true, setAt: 11 });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("removes a key named in both halves, because clearFlags is applied last", async () => {
|
|
226
|
+
const seen: FlagsMergePatch[] = [];
|
|
227
|
+
setClient(
|
|
228
|
+
clientHolding(
|
|
229
|
+
address({ flags: { muted: { value: true, setAt: 10 } } }),
|
|
230
|
+
seen,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const response = await updateAddress(
|
|
235
|
+
updateContextFor({
|
|
236
|
+
flags: { muted: { value: true, setAt: 20 } },
|
|
237
|
+
clearFlags: ["muted"],
|
|
238
|
+
}),
|
|
239
|
+
eventFor(SUB),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(seen, [{ muted: null }]);
|
|
243
|
+
assert.equal(response.flags.muted, undefined);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/handlers/address.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { AddressItem, FlagsMergePatch } from "@remit/data-ports";
|
|
6
6
|
import { ForbiddenError } from "@remit/data-ports/errors";
|
|
7
|
+
import { AddressFlagKey } from "@remit/domain-enums";
|
|
7
8
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
8
9
|
import type { Context } from "openapi-backend";
|
|
9
10
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -31,39 +32,43 @@ export const toAddressResponse = (item: AddressItem): AddressResponse => ({
|
|
|
31
32
|
updatedAt: item.updatedAt,
|
|
32
33
|
});
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
"trusted",
|
|
36
|
-
"blocked",
|
|
37
|
-
"muted",
|
|
38
|
-
"vip",
|
|
39
|
-
"junkOnly",
|
|
40
|
-
"category",
|
|
41
|
-
"autoArchive",
|
|
42
|
-
"unsubscribed",
|
|
43
|
-
] as const;
|
|
35
|
+
type FlagKey = (typeof AddressFlagKey)[keyof typeof AddressFlagKey];
|
|
44
36
|
|
|
45
|
-
|
|
37
|
+
const FLAG_KEYS = Object.values(AddressFlagKey) as readonly FlagKey[];
|
|
46
38
|
|
|
47
39
|
/**
|
|
48
|
-
* Translate the wire-format `
|
|
40
|
+
* Translate the wire-format `UpdateAddressInput` into a service-level
|
|
49
41
|
* `FlagsMergePatch`. Only known flag keys are forwarded; unknown keys are
|
|
50
42
|
* silently dropped (a TypeSpec-only schema means unknown keys are a client
|
|
51
|
-
* bug, not a security risk).
|
|
43
|
+
* bug, not a security risk).
|
|
44
|
+
*
|
|
45
|
+
* `clearFlags` is the removal form for every flag whatever its value type, and
|
|
46
|
+
* is applied after `flags`, so a key named in both ends up removed. The `null`
|
|
47
|
+
* alternative on a flag key stays honoured for callers that can express it,
|
|
48
|
+
* but it is unreachable over OAS 3.0: TypeSpec emits a nullable `$ref` as
|
|
49
|
+
* `allOf`, and the request validator drops the `nullable` there.
|
|
52
50
|
*/
|
|
53
51
|
export const buildFlagsPatch = (
|
|
54
|
-
input: UpdateAddressInput
|
|
52
|
+
input: UpdateAddressInput | undefined,
|
|
55
53
|
): FlagsMergePatch => {
|
|
56
54
|
if (!input) return {};
|
|
57
55
|
const patch = {} as Record<FlagKey, unknown>;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const flags = input.flags;
|
|
57
|
+
if (flags) {
|
|
58
|
+
for (const key of FLAG_KEYS) {
|
|
59
|
+
if (!(key in flags)) continue;
|
|
60
|
+
const value = (flags as Record<FlagKey, unknown>)[key];
|
|
61
|
+
if (value === null) {
|
|
62
|
+
patch[key] = null;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (value === undefined) continue;
|
|
66
|
+
patch[key] = value;
|
|
64
67
|
}
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
}
|
|
69
|
+
for (const key of input.clearFlags ?? []) {
|
|
70
|
+
if (!FLAG_KEYS.includes(key)) continue;
|
|
71
|
+
patch[key] = null;
|
|
67
72
|
}
|
|
68
73
|
return patch as FlagsMergePatch;
|
|
69
74
|
};
|
|
@@ -122,7 +127,7 @@ export const AddressDetailOperations: Record<
|
|
|
122
127
|
throw new ForbiddenError(`Address ${addressId} not in account config`);
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
const patch = buildFlagsPatch(body
|
|
130
|
+
const patch = buildFlagsPatch(body);
|
|
126
131
|
if (Object.keys(patch).length === 0) {
|
|
127
132
|
return toAddressResponse(existing);
|
|
128
133
|
}
|
|
@@ -9,7 +9,9 @@ import { describe, it } from "node:test";
|
|
|
9
9
|
import {
|
|
10
10
|
computeMessagesSynced,
|
|
11
11
|
deriveMailboxPhase,
|
|
12
|
+
type MailboxProgressFields,
|
|
12
13
|
type MailboxSyncFields,
|
|
14
|
+
toMailboxSyncProgress,
|
|
13
15
|
} from "./sync-progress.js";
|
|
14
16
|
|
|
15
17
|
// ─── deriveMailboxPhase tests ───────────────────────────────────────────────
|
|
@@ -156,3 +158,48 @@ describe("computeMessagesSynced", () => {
|
|
|
156
158
|
assert.equal(computeMessagesSynced(mailbox), 50);
|
|
157
159
|
});
|
|
158
160
|
});
|
|
161
|
+
|
|
162
|
+
// ─── toMailboxSyncProgress tests ────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
const mailboxRow = (
|
|
165
|
+
overrides: Partial<MailboxProgressFields> = {},
|
|
166
|
+
): MailboxProgressFields => ({
|
|
167
|
+
mailboxId: "mb-1",
|
|
168
|
+
fullPath: "INBOX",
|
|
169
|
+
lastMessageSyncAt: 1_700_000_000_000,
|
|
170
|
+
initialSyncCompletedAt: 1_700_000_000_001,
|
|
171
|
+
messageCount: 10,
|
|
172
|
+
lastSyncUid: 1,
|
|
173
|
+
highWaterMarkUid: 100,
|
|
174
|
+
...overrides,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("toMailboxSyncProgress", () => {
|
|
178
|
+
// Issue #771: the client cannot tell an arrival from a departure by count —
|
|
179
|
+
// one message in and one out nets to zero. The highest UID only ever moves
|
|
180
|
+
// up, so the projection has to carry it or the arrival stays invisible.
|
|
181
|
+
it("carries the mailbox's highest UID", () => {
|
|
182
|
+
assert.equal(
|
|
183
|
+
toMailboxSyncProgress(mailboxRow({ highWaterMarkUid: 4321 }))
|
|
184
|
+
.highWaterMarkUid,
|
|
185
|
+
4321,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("reports the sentinel 0 for a mailbox that has never taken a message", () => {
|
|
190
|
+
assert.equal(
|
|
191
|
+
toMailboxSyncProgress(mailboxRow({ highWaterMarkUid: 0 }))
|
|
192
|
+
.highWaterMarkUid,
|
|
193
|
+
0,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("projects identity, phase and totals alongside it", () => {
|
|
198
|
+
const progress = toMailboxSyncProgress(mailboxRow());
|
|
199
|
+
assert.equal(progress.mailboxId, "mb-1");
|
|
200
|
+
assert.equal(progress.fullPath, "INBOX");
|
|
201
|
+
assert.equal(progress.phase, "complete");
|
|
202
|
+
assert.equal(progress.messagesTotal, 10);
|
|
203
|
+
assert.equal(progress.lastSyncedAt, 1_700_000_000_000);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -10,6 +10,21 @@ export type MailboxSyncFields = Pick<
|
|
|
10
10
|
| "highWaterMarkUid"
|
|
11
11
|
>;
|
|
12
12
|
|
|
13
|
+
/** A mailbox row, as far as the sync-status projection reads it. */
|
|
14
|
+
export type MailboxProgressFields = MailboxSyncFields &
|
|
15
|
+
Pick<MailboxItem, "mailboxId" | "fullPath">;
|
|
16
|
+
|
|
17
|
+
/** The per-mailbox entry `getSyncStatus` returns. */
|
|
18
|
+
export interface MailboxSyncProgress {
|
|
19
|
+
mailboxId: string;
|
|
20
|
+
fullPath: string;
|
|
21
|
+
phase: string;
|
|
22
|
+
messagesTotal: number;
|
|
23
|
+
messagesSynced: number;
|
|
24
|
+
highWaterMarkUid: number;
|
|
25
|
+
lastSyncedAt: number | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Derive a per-mailbox sync phase from worker-written sync state.
|
|
15
30
|
*
|
|
@@ -62,3 +77,23 @@ export const computeMessagesSynced = (mailbox: MailboxSyncFields): number => {
|
|
|
62
77
|
|
|
63
78
|
return Math.min(Math.max(synced, 0), messagesTotal);
|
|
64
79
|
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Project a mailbox row into its sync-status entry.
|
|
83
|
+
*
|
|
84
|
+
* `highWaterMarkUid` is carried through because a total cannot tell an arrival
|
|
85
|
+
* from a departure: a message landing in the same sync round that another one
|
|
86
|
+
* leaves nets to zero, and the client's freshness check saw nothing (#771). The
|
|
87
|
+
* highest UID only ever moves up, so it names the arrival on its own.
|
|
88
|
+
*/
|
|
89
|
+
export const toMailboxSyncProgress = (
|
|
90
|
+
mailbox: MailboxProgressFields,
|
|
91
|
+
): MailboxSyncProgress => ({
|
|
92
|
+
mailboxId: mailbox.mailboxId,
|
|
93
|
+
fullPath: mailbox.fullPath,
|
|
94
|
+
phase: deriveMailboxPhase(mailbox),
|
|
95
|
+
messagesTotal: mailbox.messageCount ?? 0,
|
|
96
|
+
messagesSynced: computeMessagesSynced(mailbox),
|
|
97
|
+
highWaterMarkUid: mailbox.highWaterMarkUid ?? 0,
|
|
98
|
+
lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
|
|
99
|
+
});
|
package/src/handlers/sync.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { sqsClient } from "../service/sqs.js";
|
|
|
12
12
|
import { triggerAccountSync } from "../service/trigger-sync.js";
|
|
13
13
|
import type { OperationHandler, SyncOperationIds } from "../types.js";
|
|
14
14
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
15
|
-
import {
|
|
15
|
+
import { toMailboxSyncProgress } from "./sync-progress.js";
|
|
16
16
|
|
|
17
17
|
interface SyncTriggerDeps {
|
|
18
18
|
sqsClient: SQSClient;
|
|
@@ -114,14 +114,7 @@ export const SyncOperations: Record<
|
|
|
114
114
|
mailboxCountTotal: account.mailboxCountTotal,
|
|
115
115
|
mailboxCountSynced: account.mailboxCountSynced,
|
|
116
116
|
lastSyncAt: account.lastSyncAt,
|
|
117
|
-
mailboxes: mailboxes.map(
|
|
118
|
-
mailboxId: mailbox.mailboxId,
|
|
119
|
-
fullPath: mailbox.fullPath,
|
|
120
|
-
phase: deriveMailboxPhase(mailbox),
|
|
121
|
-
messagesTotal: mailbox.messageCount ?? 0,
|
|
122
|
-
messagesSynced: computeMessagesSynced(mailbox),
|
|
123
|
-
lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
|
|
124
|
-
})),
|
|
117
|
+
mailboxes: mailboxes.map(toMailboxSyncProgress),
|
|
125
118
|
};
|
|
126
119
|
},
|
|
127
120
|
};
|