@remit/drizzle-service 0.0.103 → 0.0.104
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 +4 -3
- package/src/repos/account.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-default.d1.test.ts +4 -0
- package/src/repos/calendar-default.sqlite.test.ts +3 -76
- package/src/repos/calendar-default.suite.ts +68 -0
- package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-put.d1.test.ts +4 -0
- package/src/repos/calendar-put.sqlite.test.ts +3 -209
- package/src/repos/calendar-put.suite.ts +210 -0
- package/src/repos/cascade-delete.ts +1 -1
- package/src/repos/filter.d1.test.ts +4 -0
- package/src/repos/filter.suite.ts +476 -0
- package/src/repos/filter.test.ts +3 -472
- package/src/repos/i4-account-config.d1.test.ts +4 -0
- package/src/repos/i4-account-config.suite.ts +131 -0
- package/src/repos/i4-account-config.test.ts +3 -127
- package/src/repos/i4-account-export-request.d1.test.ts +4 -0
- package/src/repos/i4-account-export-request.suite.ts +44 -0
- package/src/repos/i4-account-export-request.test.ts +3 -40
- package/src/repos/i4-account-setting.d1.test.ts +4 -0
- package/src/repos/i4-account-setting.suite.ts +100 -0
- package/src/repos/i4-account-setting.test.ts +3 -93
- package/src/repos/label.conformance.d1.test.ts +20 -0
- package/src/repos/message.sqlite.test.ts +54 -0
- package/src/repos/message.ts +66 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +23 -19
- package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
- package/src/repos/thread-message.sqlite.test.ts +6 -4
- package/src/repos/thread-message.test.ts +23 -11
- package/src/repos/thread-message.ts +13 -9
- package/src/repos/thread-search-predicates.ts +36 -63
- package/src/schema/message-data.ts +3 -0
- package/src/schema-sets/mail.ts +1 -0
- package/src/search/thread-message-search.sqlite.test.ts +49 -8
- package/src/search/thread-message-search.ts +13 -4
- package/src/test-d1.ts +40 -0
- package/src/test-db-sqlite.ts +1 -1
- package/src/test-shipped-sqlite-schema.ts +8 -6
- package/src/test-stores.ts +30 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
prepareCalendarObject,
|
|
5
|
+
provisionDefaultCalendar,
|
|
6
|
+
putCalendarObject,
|
|
7
|
+
} from "@remit/calendar-service";
|
|
8
|
+
import type {
|
|
9
|
+
CalendarRepositories,
|
|
10
|
+
CalendarWrite,
|
|
11
|
+
ICalendarStore,
|
|
12
|
+
} from "@remit/data-ports";
|
|
13
|
+
import type { RepositoryDb } from "../db.js";
|
|
14
|
+
import {
|
|
15
|
+
calendarEventIndexTable,
|
|
16
|
+
calendarObjectTable,
|
|
17
|
+
calendarTable,
|
|
18
|
+
} from "../schema.js";
|
|
19
|
+
import { composeSqliteRepositories } from "../sqlite-repositories.js";
|
|
20
|
+
import type { OpenTestStores } from "../test-stores.js";
|
|
21
|
+
|
|
22
|
+
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
23
|
+
|
|
24
|
+
const RESOURCE = [
|
|
25
|
+
"BEGIN:VCALENDAR",
|
|
26
|
+
"VERSION:2.0",
|
|
27
|
+
"BEGIN:VEVENT",
|
|
28
|
+
"UID:atomic@example.com",
|
|
29
|
+
"DTSTART:20260826T090000Z",
|
|
30
|
+
"DTEND:20260826T100000Z",
|
|
31
|
+
"SUMMARY:Quarterly review",
|
|
32
|
+
"RRULE:FREQ=WEEKLY;COUNT=3",
|
|
33
|
+
"END:VEVENT",
|
|
34
|
+
"END:VCALENDAR",
|
|
35
|
+
"",
|
|
36
|
+
].join("\r\n");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Appends a write that fails to every commit: a resource put into a collection
|
|
40
|
+
* that does not exist, which the store refuses at its last statement. Every
|
|
41
|
+
* statement of the real write set has run by then, so a failure there is the
|
|
42
|
+
* case that proves the boundary.
|
|
43
|
+
*/
|
|
44
|
+
class FailingLastWriteStore implements ICalendarStore {
|
|
45
|
+
constructor(private inner: ICalendarStore) {}
|
|
46
|
+
|
|
47
|
+
get repos(): CalendarRepositories {
|
|
48
|
+
return this.inner.repos;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async commit(writes: readonly CalendarWrite[]): Promise<void> {
|
|
52
|
+
const orphan = await prepareCalendarObject(
|
|
53
|
+
{ timezone: "" },
|
|
54
|
+
{
|
|
55
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
56
|
+
calendarId: "no-such-calendar",
|
|
57
|
+
resourceName: "orphan.ics",
|
|
58
|
+
icalData: RESOURCE,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
assert.ok(orphan.ok);
|
|
62
|
+
await this.inner.commit([...writes, orphan.value]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const calendarPutSuite = (open: OpenTestStores): void => {
|
|
67
|
+
describe("the calendar write path against sqlite", () => {
|
|
68
|
+
let db: RepositoryDb<Record<string, unknown>>;
|
|
69
|
+
let close: () => Promise<void>;
|
|
70
|
+
let store: ICalendarStore;
|
|
71
|
+
let calendarId: string;
|
|
72
|
+
|
|
73
|
+
before(async () => {
|
|
74
|
+
const opened = await open();
|
|
75
|
+
db = opened.stores.app.db;
|
|
76
|
+
close = opened.close;
|
|
77
|
+
store = composeSqliteRepositories(opened.stores).calendarStore;
|
|
78
|
+
const collection = await provisionDefaultCalendar(
|
|
79
|
+
store,
|
|
80
|
+
ACCOUNT_CONFIG_ID,
|
|
81
|
+
);
|
|
82
|
+
calendarId = collection.calendarId;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
after(() => close());
|
|
86
|
+
|
|
87
|
+
const objects = () => db.select().from(calendarObjectTable);
|
|
88
|
+
const occurrences = () => db.select().from(calendarEventIndexTable);
|
|
89
|
+
|
|
90
|
+
test("writes the object, its occurrences and the sequence bump together", async () => {
|
|
91
|
+
const result = await putCalendarObject(store, {
|
|
92
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
93
|
+
calendarId,
|
|
94
|
+
resourceName: "review.ics",
|
|
95
|
+
icalData: RESOURCE,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.ok(result.ok);
|
|
99
|
+
assert.equal(result.value.icalData, RESOURCE);
|
|
100
|
+
assert.equal(
|
|
101
|
+
(await objects())[0]?.icalData,
|
|
102
|
+
RESOURCE,
|
|
103
|
+
"the stored row holds the input bytes, CRLF and all",
|
|
104
|
+
);
|
|
105
|
+
assert.equal(result.value.syncSequence, 1);
|
|
106
|
+
assert.equal((await objects()).length, 1);
|
|
107
|
+
assert.equal(
|
|
108
|
+
(await occurrences()).filter(
|
|
109
|
+
(row) => row.calendarObjectId === result.value.calendarObjectId,
|
|
110
|
+
).length,
|
|
111
|
+
3,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a failing write in the batch leaves no object behind", async () => {
|
|
116
|
+
const before = await objects();
|
|
117
|
+
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
putCalendarObject(new FailingLastWriteStore(store), {
|
|
120
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
121
|
+
calendarId,
|
|
122
|
+
resourceName: "doomed.ics",
|
|
123
|
+
icalData: RESOURCE,
|
|
124
|
+
}),
|
|
125
|
+
/NOT NULL constraint failed/,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const after = await objects();
|
|
129
|
+
assert.equal(after.length, before.length);
|
|
130
|
+
assert.equal(
|
|
131
|
+
after.some((row) => row.resourceName === "doomed.ics"),
|
|
132
|
+
false,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a failing write in the batch leaves the collection's sequence where it was", async () => {
|
|
137
|
+
const [before] = await db.select().from(calendarTable);
|
|
138
|
+
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
putCalendarObject(new FailingLastWriteStore(store), {
|
|
141
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
142
|
+
calendarId,
|
|
143
|
+
resourceName: "doomed-again.ics",
|
|
144
|
+
icalData: RESOURCE,
|
|
145
|
+
}),
|
|
146
|
+
/NOT NULL constraint failed/,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const [after] = await db.select().from(calendarTable);
|
|
150
|
+
assert.equal(after?.syncSequence, before?.syncSequence);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a refused resource writes nothing at all", async () => {
|
|
154
|
+
const before = await objects();
|
|
155
|
+
const [collectionBefore] = await db.select().from(calendarTable);
|
|
156
|
+
|
|
157
|
+
const result = await putCalendarObject(store, {
|
|
158
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
159
|
+
calendarId,
|
|
160
|
+
resourceName: "backwards.ics",
|
|
161
|
+
icalData: [
|
|
162
|
+
"BEGIN:VCALENDAR",
|
|
163
|
+
"VERSION:2.0",
|
|
164
|
+
"BEGIN:VEVENT",
|
|
165
|
+
"UID:backwards@example.com",
|
|
166
|
+
"DTSTART:20260826T100000Z",
|
|
167
|
+
"DTEND:20260826T090000Z",
|
|
168
|
+
"END:VEVENT",
|
|
169
|
+
"END:VCALENDAR",
|
|
170
|
+
"",
|
|
171
|
+
].join("\r\n"),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.ok(!result.ok);
|
|
175
|
+
assert.equal(result.error.code, "BackwardsEnd");
|
|
176
|
+
assert.equal((await objects()).length, before.length);
|
|
177
|
+
const [collectionAfter] = await db.select().from(calendarTable);
|
|
178
|
+
assert.equal(
|
|
179
|
+
collectionAfter?.syncSequence,
|
|
180
|
+
collectionBefore?.syncSequence,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("rewriting a resource replaces its occurrences rather than adding to them", async () => {
|
|
185
|
+
const first = await putCalendarObject(store, {
|
|
186
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
187
|
+
calendarId,
|
|
188
|
+
resourceName: "shrinking.ics",
|
|
189
|
+
icalData: RESOURCE,
|
|
190
|
+
});
|
|
191
|
+
assert.ok(first.ok);
|
|
192
|
+
|
|
193
|
+
const second = await putCalendarObject(store, {
|
|
194
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
195
|
+
calendarId,
|
|
196
|
+
resourceName: "shrinking.ics",
|
|
197
|
+
icalData: RESOURCE.replace("COUNT=3", "COUNT=1"),
|
|
198
|
+
});
|
|
199
|
+
assert.ok(second.ok);
|
|
200
|
+
|
|
201
|
+
assert.equal(second.value.calendarObjectId, first.value.calendarObjectId);
|
|
202
|
+
assert.equal(
|
|
203
|
+
(await occurrences()).filter(
|
|
204
|
+
(row) => row.calendarObjectId === second.value.calendarObjectId,
|
|
205
|
+
).length,
|
|
206
|
+
1,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
};
|
|
@@ -77,7 +77,7 @@ const groupByType = (
|
|
|
77
77
|
* first, then the app rows, then the search documents of the deleted thread
|
|
78
78
|
* messages. Every statement is keyed, so a rerun after a failure between two
|
|
79
79
|
* batches deletes what is left. Message subtrees
|
|
80
|
-
* (message +
|
|
80
|
+
* (message + ten child tables) are deleted set-wise by message id; the remaining
|
|
81
81
|
* entity types are deleted by their enumerated primary keys. AccountConfig is
|
|
82
82
|
* never deleted here — the caller removes it last through its repository, the
|
|
83
83
|
* cascade-in-progress marker.
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
FilterClauseField,
|
|
5
|
+
FilterDisabledReason,
|
|
6
|
+
FilterScope,
|
|
7
|
+
FilterState,
|
|
8
|
+
} from "@remit/domain-enums";
|
|
9
|
+
import { NotFoundError } from "../error.js";
|
|
10
|
+
import { randomId } from "../id.js";
|
|
11
|
+
import type { OpenTestStores } from "../test-stores.js";
|
|
12
|
+
import { FilterRepo } from "./filter.js";
|
|
13
|
+
|
|
14
|
+
const CLAUSES = [{ field: FilterClauseField.Subject, value: "booking" }];
|
|
15
|
+
|
|
16
|
+
export const filterRepoSuite = (open: OpenTestStores): void => {
|
|
17
|
+
describe("FilterRepo", () => {
|
|
18
|
+
let close: () => Promise<void>;
|
|
19
|
+
let repo: FilterRepo;
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
const opened = await open();
|
|
23
|
+
close = opened.close;
|
|
24
|
+
repo = new FilterRepo(opened.stores.app.db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(async () => {
|
|
28
|
+
await close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("create fills in every defaulted field, never leaving it absent", async () => {
|
|
32
|
+
const accountConfigId = randomId();
|
|
33
|
+
const filter = await repo.create({
|
|
34
|
+
accountConfigId,
|
|
35
|
+
name: "Booking confirmations",
|
|
36
|
+
scope: FilterScope.Standing,
|
|
37
|
+
literalClauses: CLAUSES,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.equal(filter.state, FilterState.Active);
|
|
41
|
+
assert.equal(filter.disabledReason, FilterDisabledReason.None);
|
|
42
|
+
assert.deepEqual(filter.literalClauses, CLAUSES);
|
|
43
|
+
assert.equal(filter.actionLabelId, "None");
|
|
44
|
+
assert.equal(filter.actionMailboxId, "None");
|
|
45
|
+
assert.ok(
|
|
46
|
+
filter.ruleChangedAt > 0,
|
|
47
|
+
"ruleChangedAt should be set on create",
|
|
48
|
+
);
|
|
49
|
+
assert.ok(
|
|
50
|
+
filter.actionChangedAt > 0,
|
|
51
|
+
"actionChangedAt should be set on create",
|
|
52
|
+
);
|
|
53
|
+
assert.equal(filter.expiresAt, undefined);
|
|
54
|
+
assert.equal(filter.ttl, undefined);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a disabled filter keeps its reason and drops out of the Active listing (#1103)", async () => {
|
|
58
|
+
const accountConfigId = randomId();
|
|
59
|
+
const filter = await repo.create({
|
|
60
|
+
accountConfigId,
|
|
61
|
+
name: "Invoices",
|
|
62
|
+
scope: FilterScope.Standing,
|
|
63
|
+
literalClauses: CLAUSES,
|
|
64
|
+
state: FilterState.Disabled,
|
|
65
|
+
disabledReason: FilterDisabledReason.AwaitingFolder,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.equal(filter.state, FilterState.Disabled);
|
|
69
|
+
assert.equal(filter.disabledReason, FilterDisabledReason.AwaitingFolder);
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
await repo.listByAccountAndState(accountConfigId, FilterState.Active),
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const enabled = await repo.update(accountConfigId, filter.filterId, {
|
|
76
|
+
state: FilterState.Active,
|
|
77
|
+
disabledReason: FilterDisabledReason.None,
|
|
78
|
+
});
|
|
79
|
+
assert.equal(enabled.disabledReason, FilterDisabledReason.None);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
(
|
|
82
|
+
await repo.listByAccountAndState(accountConfigId, FilterState.Active)
|
|
83
|
+
).map((item) => item.filterId),
|
|
84
|
+
[filter.filterId],
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a Temporary filter round-trips expiresAt and ttl", async () => {
|
|
89
|
+
const accountConfigId = randomId();
|
|
90
|
+
const expiresAt = "2026-08-01T00:00:00+02:00";
|
|
91
|
+
const ttl = Math.floor(new Date(expiresAt).getTime() / 1000);
|
|
92
|
+
|
|
93
|
+
const filter = await repo.create({
|
|
94
|
+
accountConfigId,
|
|
95
|
+
name: "Until my trip",
|
|
96
|
+
scope: FilterScope.Temporary,
|
|
97
|
+
literalClauses: CLAUSES,
|
|
98
|
+
expiresAt,
|
|
99
|
+
ttl,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(filter.scope, FilterScope.Temporary);
|
|
103
|
+
assert.equal(filter.expiresAt, expiresAt);
|
|
104
|
+
assert.equal(filter.ttl, ttl);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("create round-trips literal clauses", async () => {
|
|
108
|
+
const accountConfigId = randomId();
|
|
109
|
+
const literalClauses = [
|
|
110
|
+
{ field: "From", value: "billing@example.com" },
|
|
111
|
+
{ field: "Subject", value: "invoice" },
|
|
112
|
+
] as const;
|
|
113
|
+
|
|
114
|
+
const filter = await repo.create({
|
|
115
|
+
accountConfigId,
|
|
116
|
+
name: "Invoices",
|
|
117
|
+
scope: FilterScope.Standing,
|
|
118
|
+
literalClauses: [...literalClauses],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(filter.literalClauses, literalClauses);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("get throws NotFoundError for a missing filter", async () => {
|
|
125
|
+
await assert.rejects(repo.get(randomId(), randomId()), NotFoundError);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("update does not bump ruleChangedAt on a cosmetic rename", async () => {
|
|
129
|
+
const accountConfigId = randomId();
|
|
130
|
+
const filter = await repo.create({
|
|
131
|
+
accountConfigId,
|
|
132
|
+
name: "Newsletter admin",
|
|
133
|
+
scope: FilterScope.Standing,
|
|
134
|
+
literalClauses: CLAUSES,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const renamed = await repo.update(accountConfigId, filter.filterId, {
|
|
138
|
+
name: "Newsletter admin (renamed)",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.equal(renamed.name, "Newsletter admin (renamed)");
|
|
142
|
+
assert.equal(
|
|
143
|
+
renamed.ruleChangedAt,
|
|
144
|
+
filter.ruleChangedAt,
|
|
145
|
+
"a name-only edit must not move ruleChangedAt (RFC 034 Decision 3.2)",
|
|
146
|
+
);
|
|
147
|
+
assert.equal(
|
|
148
|
+
renamed.actionChangedAt,
|
|
149
|
+
filter.actionChangedAt,
|
|
150
|
+
"a name-only edit must not move actionChangedAt either",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("update bumps both ruleChangedAt and actionChangedAt when the action changes", async () => {
|
|
155
|
+
const accountConfigId = randomId();
|
|
156
|
+
const filter = await repo.create({
|
|
157
|
+
accountConfigId,
|
|
158
|
+
name: "Move to archive",
|
|
159
|
+
scope: FilterScope.Standing,
|
|
160
|
+
literalClauses: CLAUSES,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
164
|
+
|
|
165
|
+
const updated = await repo.update(accountConfigId, filter.filterId, {
|
|
166
|
+
actionMailboxId: randomId(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.ok(
|
|
170
|
+
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
171
|
+
"an action edit must move ruleChangedAt forward",
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
updated.actionChangedAt > filter.actionChangedAt,
|
|
175
|
+
"an action edit must move actionChangedAt forward too — it is what a matching message's exclusive move goes by",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("update bumps ruleChangedAt but NOT actionChangedAt when scope changes (reader #266, #384)", async () => {
|
|
180
|
+
const accountConfigId = randomId();
|
|
181
|
+
const filter = await repo.create({
|
|
182
|
+
accountConfigId,
|
|
183
|
+
name: "Trip out-of-office",
|
|
184
|
+
scope: FilterScope.Standing,
|
|
185
|
+
literalClauses: CLAUSES,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
189
|
+
|
|
190
|
+
const expiresAt = "2027-01-01T00:00:00+00:00";
|
|
191
|
+
const updated = await repo.update(accountConfigId, filter.filterId, {
|
|
192
|
+
scope: FilterScope.Temporary,
|
|
193
|
+
expiresAt,
|
|
194
|
+
ttl: Math.floor(new Date(expiresAt).getTime() / 1000),
|
|
195
|
+
state: FilterState.Active,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
assert.equal(updated.scope, FilterScope.Temporary);
|
|
199
|
+
assert.equal(updated.expiresAt, expiresAt);
|
|
200
|
+
assert.ok(
|
|
201
|
+
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
202
|
+
"a scope edit must move ruleChangedAt forward, so a lapsed filter's back-application can be offered again",
|
|
203
|
+
);
|
|
204
|
+
assert.equal(
|
|
205
|
+
updated.actionChangedAt,
|
|
206
|
+
filter.actionChangedAt,
|
|
207
|
+
"a scope edit changes the filter's lifecycle, not what it matches or does — it must not move actionChangedAt, or it would wrongly promote the filter to move-winner",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("update bumps ruleChangedAt but NOT actionChangedAt when only expiresAt changes (reader #266, #384)", async () => {
|
|
212
|
+
const accountConfigId = randomId();
|
|
213
|
+
const initialExpiresAt = "2026-08-01T00:00:00+00:00";
|
|
214
|
+
const filter = await repo.create({
|
|
215
|
+
accountConfigId,
|
|
216
|
+
name: "Until my trip",
|
|
217
|
+
scope: FilterScope.Temporary,
|
|
218
|
+
literalClauses: CLAUSES,
|
|
219
|
+
expiresAt: initialExpiresAt,
|
|
220
|
+
ttl: Math.floor(new Date(initialExpiresAt).getTime() / 1000),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
224
|
+
|
|
225
|
+
const extendedExpiresAt = "2026-09-01T00:00:00+00:00";
|
|
226
|
+
const updated = await repo.update(accountConfigId, filter.filterId, {
|
|
227
|
+
expiresAt: extendedExpiresAt,
|
|
228
|
+
ttl: Math.floor(new Date(extendedExpiresAt).getTime() / 1000),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
assert.equal(updated.expiresAt, extendedExpiresAt);
|
|
232
|
+
assert.ok(
|
|
233
|
+
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
234
|
+
"an expiresAt edit must move ruleChangedAt forward",
|
|
235
|
+
);
|
|
236
|
+
assert.equal(
|
|
237
|
+
updated.actionChangedAt,
|
|
238
|
+
filter.actionChangedAt,
|
|
239
|
+
"extending a filter's expiry alone must not promote it to move-winner (reader #384) — actionChangedAt must not move",
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("update to Standing clears expiresAt and ttl at the storage layer, even if the caller didn't null them (reader #266)", async () => {
|
|
244
|
+
const accountConfigId = randomId();
|
|
245
|
+
const expiresAt = "2026-08-01T00:00:00+00:00";
|
|
246
|
+
const filter = await repo.create({
|
|
247
|
+
accountConfigId,
|
|
248
|
+
name: "Until my trip",
|
|
249
|
+
scope: FilterScope.Temporary,
|
|
250
|
+
literalClauses: CLAUSES,
|
|
251
|
+
expiresAt,
|
|
252
|
+
ttl: Math.floor(new Date(expiresAt).getTime() / 1000),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const updated = await repo.update(accountConfigId, filter.filterId, {
|
|
256
|
+
scope: FilterScope.Standing,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
assert.equal(updated.scope, FilterScope.Standing);
|
|
260
|
+
assert.equal(updated.expiresAt, undefined);
|
|
261
|
+
assert.equal(updated.ttl, undefined);
|
|
262
|
+
|
|
263
|
+
const reread = await repo.get(accountConfigId, filter.filterId);
|
|
264
|
+
assert.equal(reread.expiresAt, undefined);
|
|
265
|
+
assert.equal(reread.ttl, undefined);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("update throws NotFoundError for a missing filter", async () => {
|
|
269
|
+
await assert.rejects(
|
|
270
|
+
repo.update(randomId(), randomId(), { name: "x" }),
|
|
271
|
+
NotFoundError,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("listByAccountConfig and listByAccountAndState scope to the account", async () => {
|
|
276
|
+
const accountConfigId = randomId();
|
|
277
|
+
const other = randomId();
|
|
278
|
+
|
|
279
|
+
const standing = await repo.create({
|
|
280
|
+
accountConfigId,
|
|
281
|
+
name: "Standing",
|
|
282
|
+
scope: FilterScope.Standing,
|
|
283
|
+
literalClauses: CLAUSES,
|
|
284
|
+
});
|
|
285
|
+
await repo.create({
|
|
286
|
+
accountConfigId: other,
|
|
287
|
+
name: "Foreign",
|
|
288
|
+
scope: FilterScope.Standing,
|
|
289
|
+
literalClauses: CLAUSES,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const all = await repo.listByAccountConfig(accountConfigId);
|
|
293
|
+
assert.equal(all.length, 1);
|
|
294
|
+
assert.equal(all[0]?.filterId, standing.filterId);
|
|
295
|
+
|
|
296
|
+
const active = await repo.listByAccountAndState(
|
|
297
|
+
accountConfigId,
|
|
298
|
+
FilterState.Active,
|
|
299
|
+
);
|
|
300
|
+
assert.equal(active.length, 1);
|
|
301
|
+
assert.equal(active[0]?.filterId, standing.filterId);
|
|
302
|
+
|
|
303
|
+
const expired = await repo.listByAccountAndState(
|
|
304
|
+
accountConfigId,
|
|
305
|
+
FilterState.Expired,
|
|
306
|
+
);
|
|
307
|
+
assert.equal(expired.length, 0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("refreshExpiry leaves a Standing filter untouched", async () => {
|
|
311
|
+
const accountConfigId = randomId();
|
|
312
|
+
const filter = await repo.create({
|
|
313
|
+
accountConfigId,
|
|
314
|
+
name: "Standing",
|
|
315
|
+
scope: FilterScope.Standing,
|
|
316
|
+
literalClauses: CLAUSES,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const result = await repo.refreshExpiry(filter);
|
|
320
|
+
assert.equal(result.state, FilterState.Active);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("refreshExpiry leaves a not-yet-expired Temporary filter Active", async () => {
|
|
324
|
+
const accountConfigId = randomId();
|
|
325
|
+
const future = new Date(Date.now() + 86_400_000).toISOString();
|
|
326
|
+
const filter = await repo.create({
|
|
327
|
+
accountConfigId,
|
|
328
|
+
name: "Until tomorrow",
|
|
329
|
+
scope: FilterScope.Temporary,
|
|
330
|
+
literalClauses: CLAUSES,
|
|
331
|
+
expiresAt: future,
|
|
332
|
+
ttl: Math.floor(new Date(future).getTime() / 1000),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = await repo.refreshExpiry(filter);
|
|
336
|
+
assert.equal(result.state, FilterState.Active);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("refreshExpiry leaves a disabled filter and its reason alone (#1103)", async () => {
|
|
340
|
+
const accountConfigId = randomId();
|
|
341
|
+
const past = new Date(Date.now() - 86_400_000).toISOString();
|
|
342
|
+
const filter = await repo.create({
|
|
343
|
+
accountConfigId,
|
|
344
|
+
name: "Until yesterday, awaiting its folder",
|
|
345
|
+
scope: FilterScope.Temporary,
|
|
346
|
+
literalClauses: CLAUSES,
|
|
347
|
+
expiresAt: past,
|
|
348
|
+
ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
|
|
349
|
+
state: FilterState.Disabled,
|
|
350
|
+
disabledReason: FilterDisabledReason.AwaitingFolder,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const result = await repo.refreshExpiry(filter);
|
|
354
|
+
assert.equal(result.state, FilterState.Disabled);
|
|
355
|
+
assert.equal(result.disabledReason, FilterDisabledReason.AwaitingFolder);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("refreshExpiry lazily patches a past-expiresAt Temporary filter to Expired", async () => {
|
|
359
|
+
const accountConfigId = randomId();
|
|
360
|
+
const past = new Date(Date.now() - 86_400_000).toISOString();
|
|
361
|
+
const filter = await repo.create({
|
|
362
|
+
accountConfigId,
|
|
363
|
+
name: "Until yesterday",
|
|
364
|
+
scope: FilterScope.Temporary,
|
|
365
|
+
literalClauses: CLAUSES,
|
|
366
|
+
expiresAt: past,
|
|
367
|
+
ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
assert.equal(filter.state, FilterState.Active, "starts Active on create");
|
|
371
|
+
|
|
372
|
+
const result = await repo.refreshExpiry(filter);
|
|
373
|
+
assert.equal(result.state, FilterState.Expired);
|
|
374
|
+
|
|
375
|
+
const reread = await repo.get(accountConfigId, filter.filterId);
|
|
376
|
+
assert.equal(
|
|
377
|
+
reread.state,
|
|
378
|
+
FilterState.Expired,
|
|
379
|
+
"the patch is persisted, not just returned",
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("delete removes the row", async () => {
|
|
384
|
+
const accountConfigId = randomId();
|
|
385
|
+
const filter = await repo.create({
|
|
386
|
+
accountConfigId,
|
|
387
|
+
name: "To delete",
|
|
388
|
+
scope: FilterScope.Standing,
|
|
389
|
+
literalClauses: CLAUSES,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await repo.delete(accountConfigId, filter.filterId);
|
|
393
|
+
|
|
394
|
+
await assert.rejects(
|
|
395
|
+
repo.get(accountConfigId, filter.filterId),
|
|
396
|
+
NotFoundError,
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("listPageByAccountConfig round-trips a continuationToken across pages", async () => {
|
|
401
|
+
const accountConfigId = randomId();
|
|
402
|
+
const created = await Promise.all([
|
|
403
|
+
repo.create({
|
|
404
|
+
accountConfigId,
|
|
405
|
+
name: "One",
|
|
406
|
+
scope: FilterScope.Standing,
|
|
407
|
+
literalClauses: CLAUSES,
|
|
408
|
+
}),
|
|
409
|
+
repo.create({
|
|
410
|
+
accountConfigId,
|
|
411
|
+
name: "Two",
|
|
412
|
+
scope: FilterScope.Standing,
|
|
413
|
+
literalClauses: CLAUSES,
|
|
414
|
+
}),
|
|
415
|
+
repo.create({
|
|
416
|
+
accountConfigId,
|
|
417
|
+
name: "Three",
|
|
418
|
+
scope: FilterScope.Standing,
|
|
419
|
+
literalClauses: CLAUSES,
|
|
420
|
+
}),
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
const first = await repo.listPageByAccountConfig(accountConfigId, {
|
|
424
|
+
limit: 2,
|
|
425
|
+
});
|
|
426
|
+
assert.equal(first.items.length, 2);
|
|
427
|
+
assert.ok(
|
|
428
|
+
first.continuationToken,
|
|
429
|
+
"a full page returns a continuationToken",
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const second = await repo.listPageByAccountConfig(accountConfigId, {
|
|
433
|
+
limit: 2,
|
|
434
|
+
continuationToken: first.continuationToken,
|
|
435
|
+
});
|
|
436
|
+
assert.equal(second.items.length, 1);
|
|
437
|
+
assert.equal(
|
|
438
|
+
second.continuationToken,
|
|
439
|
+
undefined,
|
|
440
|
+
"the last page carries no continuationToken",
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const seen = new Set([
|
|
444
|
+
...first.items.map((f) => f.filterId),
|
|
445
|
+
...second.items.map((f) => f.filterId),
|
|
446
|
+
]);
|
|
447
|
+
assert.equal(
|
|
448
|
+
seen.size,
|
|
449
|
+
created.length,
|
|
450
|
+
"every filter is paged exactly once",
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe("continuation token rejection (#172)", () => {
|
|
455
|
+
for (const [label, token] of [
|
|
456
|
+
["an unparseable", "not-a-cursor"],
|
|
457
|
+
["a bare number", Buffer.from("123").toString("base64url")],
|
|
458
|
+
["a JSON array", Buffer.from("[1,2]").toString("base64url")],
|
|
459
|
+
] as const) {
|
|
460
|
+
test(`${label} token is rejected as a 400`, async () => {
|
|
461
|
+
await assert.rejects(
|
|
462
|
+
() =>
|
|
463
|
+
repo.listPageByAccountConfig(randomId(), {
|
|
464
|
+
continuationToken: token,
|
|
465
|
+
}),
|
|
466
|
+
(error: unknown) => {
|
|
467
|
+
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
468
|
+
assert.equal((error as Error).name, "BadRequestError");
|
|
469
|
+
return true;
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
};
|