@firebase-function-kits/bigquery-firestore-export 0.0.2-rc.1 → 0.0.2-rc.2
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/README.md +23 -9
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +15 -2
- package/lib/config.js.map +1 -1
- package/lib/dts.d.ts +8 -0
- package/lib/dts.d.ts.map +1 -1
- package/lib/dts.js +15 -2
- package/lib/dts.js.map +1 -1
- package/lib/export-config.js.map +1 -1
- package/lib/handlers.d.ts.map +1 -1
- package/lib/handlers.js +26 -4
- package/lib/handlers.js.map +1 -1
- package/lib/helper.d.ts.map +1 -1
- package/lib/helper.js +1 -0
- package/lib/helper.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/logs.d.ts +4 -0
- package/lib/logs.d.ts.map +1 -1
- package/lib/logs.js +18 -0
- package/lib/logs.js.map +1 -1
- package/lib/metadata.d.ts.map +1 -1
- package/npm-shrinkwrap.json +7 -1215
- package/package.json +2 -5
- package/src/config.ts +18 -2
- package/src/dts.ts +17 -2
- package/src/handlers.ts +36 -9
- package/src/helper.ts +1 -0
- package/src/logs.ts +28 -0
- package/tests/config.test.ts +54 -2
- package/tests/dts-transfer-config.test.ts +400 -0
- package/tests/dts.test.ts +17 -0
- package/tests/handlers.test.ts +113 -5
- package/tests/helper-values.test.ts +168 -0
- package/tests/helper.test.ts +65 -3
- package/tests/notification-topic.test.ts +151 -0
- package/tests/run-results.test.ts +373 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Google LLC
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as bigqueryDataTransfer from "@google-cloud/bigquery-data-transfer";
|
|
18
|
+
import { describe, expect, test, vi } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
constructUpdateTransferConfigRequest,
|
|
21
|
+
createTransferConfig,
|
|
22
|
+
type DataTransferClient,
|
|
23
|
+
getTransferConfig,
|
|
24
|
+
type TransferConfig,
|
|
25
|
+
updateTransferConfig,
|
|
26
|
+
} from "../src/dts";
|
|
27
|
+
import { resolveConfig } from "../src/export-config";
|
|
28
|
+
|
|
29
|
+
vi.mock("../src/logs", () => ({
|
|
30
|
+
createTransferConfig: vi.fn(),
|
|
31
|
+
getTransferConfigFailed: vi.fn(),
|
|
32
|
+
partitioningFieldRemovalAttempted: vi.fn(),
|
|
33
|
+
transferConfigCreated: vi.fn(),
|
|
34
|
+
transferConfigNotFound: vi.fn(),
|
|
35
|
+
transferConfigUpdated: vi.fn(),
|
|
36
|
+
updateTransferConfig: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const { UpdateTransferConfigRequest } =
|
|
40
|
+
bigqueryDataTransfer.protos.google.cloud.bigquery.datatransfer.v1;
|
|
41
|
+
|
|
42
|
+
const TRANSFER_CONFIG_NAME =
|
|
43
|
+
"projects/test-project/locations/us/transferConfigs/642f3a36-0000-2fbb-ad1d-001a114e2fa6";
|
|
44
|
+
const EXPECTED_TOPIC =
|
|
45
|
+
"projects/test-project/topics/kit-users-export-processMessages";
|
|
46
|
+
const GRPC_NOT_FOUND = 5;
|
|
47
|
+
|
|
48
|
+
const config = resolveConfig({
|
|
49
|
+
bigqueryDatasetLocation: "US",
|
|
50
|
+
projectId: "test-project",
|
|
51
|
+
instanceId: "users-export",
|
|
52
|
+
datasetId: "analytics",
|
|
53
|
+
tableName: "users",
|
|
54
|
+
queryString: "SELECT * FROM source.users",
|
|
55
|
+
displayName: "Users export",
|
|
56
|
+
schedule: "every 24 hours",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** A stored config that already matches `config` in every comparable field. */
|
|
60
|
+
function unchangedTransferConfig(): TransferConfig {
|
|
61
|
+
return {
|
|
62
|
+
name: TRANSFER_CONFIG_NAME,
|
|
63
|
+
dataSourceId: "scheduled_query",
|
|
64
|
+
destinationDatasetId: "analytics",
|
|
65
|
+
displayName: "Users export",
|
|
66
|
+
notificationPubsubTopic: EXPECTED_TOPIC,
|
|
67
|
+
schedule: "every 24 hours",
|
|
68
|
+
params: {
|
|
69
|
+
fields: {
|
|
70
|
+
query: { stringValue: "SELECT * FROM source.users" },
|
|
71
|
+
destination_table_name_template: {
|
|
72
|
+
stringValue: 'users_{run_time|"%H%M%S"}',
|
|
73
|
+
},
|
|
74
|
+
write_disposition: { stringValue: "WRITE_TRUNCATE" },
|
|
75
|
+
partitioning_field: { stringValue: "" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
} as TransferConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The stored config with only `delta` applied, so a change case can assert the
|
|
83
|
+
* whole updated config and catch edits the update mask does not mention.
|
|
84
|
+
*/
|
|
85
|
+
function transferConfigWithDelta(
|
|
86
|
+
delta: (transferConfig: TransferConfig) => void
|
|
87
|
+
): TransferConfig {
|
|
88
|
+
const expected = unchangedTransferConfig();
|
|
89
|
+
delta(expected);
|
|
90
|
+
return expected;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clientReturning(transferConfig: TransferConfig | null) {
|
|
94
|
+
return {
|
|
95
|
+
getTransferConfig: vi.fn().mockResolvedValue([transferConfig]),
|
|
96
|
+
createTransferConfig: vi.fn(),
|
|
97
|
+
updateTransferConfig: vi.fn(),
|
|
98
|
+
} as unknown as DataTransferClient & {
|
|
99
|
+
getTransferConfig: ReturnType<typeof vi.fn>;
|
|
100
|
+
createTransferConfig: ReturnType<typeof vi.fn>;
|
|
101
|
+
updateTransferConfig: ReturnType<typeof vi.fn>;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clientRejecting(err: unknown) {
|
|
106
|
+
return {
|
|
107
|
+
getTransferConfig: vi.fn().mockRejectedValue(err),
|
|
108
|
+
} as unknown as DataTransferClient;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function grpcNotFound(): Error {
|
|
112
|
+
return Object.assign(new Error("Transfer config not found"), {
|
|
113
|
+
code: GRPC_NOT_FOUND,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("constructUpdateTransferConfigRequest change detection", () => {
|
|
118
|
+
test("rejects when the stored config cannot be read", async () => {
|
|
119
|
+
await expect(
|
|
120
|
+
constructUpdateTransferConfigRequest(
|
|
121
|
+
clientReturning(null),
|
|
122
|
+
TRANSFER_CONFIG_NAME,
|
|
123
|
+
config
|
|
124
|
+
)
|
|
125
|
+
).rejects.toThrow("Transfer config not found");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("produces an empty update mask when nothing changed", async () => {
|
|
129
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
130
|
+
clientReturning(unchangedTransferConfig()),
|
|
131
|
+
TRANSFER_CONFIG_NAME,
|
|
132
|
+
config
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(request.updateMask?.paths).toEqual([]);
|
|
136
|
+
expect(request.transferConfig).toEqual(unchangedTransferConfig());
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("masks the schedule alone when only the schedule changed", async () => {
|
|
140
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
141
|
+
clientReturning(unchangedTransferConfig()),
|
|
142
|
+
TRANSFER_CONFIG_NAME,
|
|
143
|
+
{ ...config, schedule: "every 15 minutes" }
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(request.updateMask?.paths).toEqual(["schedule"]);
|
|
147
|
+
expect(request.transferConfig).toEqual(
|
|
148
|
+
transferConfigWithDelta((expected) => {
|
|
149
|
+
expected.schedule = "every 15 minutes";
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("masks params when the destination table name changed", async () => {
|
|
155
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
156
|
+
clientReturning(unchangedTransferConfig()),
|
|
157
|
+
TRANSFER_CONFIG_NAME,
|
|
158
|
+
{ ...config, tableName: "different_table" }
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(request.updateMask?.paths).toEqual(["params"]);
|
|
162
|
+
expect(request.transferConfig).toEqual(
|
|
163
|
+
transferConfigWithDelta((expected) => {
|
|
164
|
+
expected.params.fields.destination_table_name_template.stringValue =
|
|
165
|
+
'different_table_{run_time|"%H%M%S"}';
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("masks params when the query changed", async () => {
|
|
171
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
172
|
+
clientReturning(unchangedTransferConfig()),
|
|
173
|
+
TRANSFER_CONFIG_NAME,
|
|
174
|
+
{ ...config, queryString: "SELECT * FROM source.accounts" }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(request.updateMask?.paths).toEqual(["params"]);
|
|
178
|
+
expect(request.transferConfig).toEqual(
|
|
179
|
+
transferConfigWithDelta((expected) => {
|
|
180
|
+
expected.params.fields.query.stringValue =
|
|
181
|
+
"SELECT * FROM source.accounts";
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("leaves an unset partitioning field untouched when other params change", async () => {
|
|
187
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
188
|
+
clientReturning(unchangedTransferConfig()),
|
|
189
|
+
TRANSFER_CONFIG_NAME,
|
|
190
|
+
{
|
|
191
|
+
...config,
|
|
192
|
+
partitioningField: undefined,
|
|
193
|
+
queryString: "SELECT * FROM source.accounts",
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(request.updateMask?.paths).toEqual(["params"]);
|
|
198
|
+
expect(request.transferConfig).toEqual(
|
|
199
|
+
transferConfigWithDelta((expected) => {
|
|
200
|
+
expected.params.fields.query.stringValue =
|
|
201
|
+
"SELECT * FROM source.accounts";
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("masks the notification topic when the stored one drifted", async () => {
|
|
207
|
+
const stored = unchangedTransferConfig();
|
|
208
|
+
stored.notificationPubsubTopic = "projects/test-project/topics/wrong-topic";
|
|
209
|
+
|
|
210
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
211
|
+
clientReturning(stored),
|
|
212
|
+
TRANSFER_CONFIG_NAME,
|
|
213
|
+
{ ...config, schedule: "every 15 minutes" }
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(request.updateMask?.paths).toEqual([
|
|
217
|
+
"schedule",
|
|
218
|
+
"notification_pubsub_topic",
|
|
219
|
+
]);
|
|
220
|
+
expect(request.transferConfig).toEqual(
|
|
221
|
+
transferConfigWithDelta((expected) => {
|
|
222
|
+
expected.schedule = "every 15 minutes";
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("leaves an extension-named topic alone when PUB_SUB_TOPIC matches it", async () => {
|
|
228
|
+
const extensionTopic =
|
|
229
|
+
"projects/test-project/topics/ext-users-export-processMessages";
|
|
230
|
+
const stored = unchangedTransferConfig();
|
|
231
|
+
stored.notificationPubsubTopic = extensionTopic;
|
|
232
|
+
|
|
233
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
234
|
+
clientReturning(stored),
|
|
235
|
+
TRANSFER_CONFIG_NAME,
|
|
236
|
+
{ ...config, pubSubTopic: "ext-users-export-processMessages" }
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(request.updateMask?.paths).toEqual([]);
|
|
240
|
+
expect(request.transferConfig?.notificationPubsubTopic).toBe(
|
|
241
|
+
extensionTopic
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("masks the destination dataset when it changed", async () => {
|
|
246
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
247
|
+
clientReturning(unchangedTransferConfig()),
|
|
248
|
+
TRANSFER_CONFIG_NAME,
|
|
249
|
+
{ ...config, datasetId: "new_dataset_id" }
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(request.updateMask?.paths).toEqual(["destination_dataset_id"]);
|
|
253
|
+
expect(request.transferConfig).toEqual(
|
|
254
|
+
transferConfigWithDelta((expected) => {
|
|
255
|
+
expected.destinationDatasetId = "new_dataset_id";
|
|
256
|
+
})
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("adds a partitioning field that was not previously set", async () => {
|
|
261
|
+
const request = await constructUpdateTransferConfigRequest(
|
|
262
|
+
clientReturning(unchangedTransferConfig()),
|
|
263
|
+
TRANSFER_CONFIG_NAME,
|
|
264
|
+
{ ...config, partitioningField: "created_at" }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(request.updateMask?.paths).toEqual(["params"]);
|
|
268
|
+
expect(request.transferConfig).toEqual(
|
|
269
|
+
transferConfigWithDelta((expected) => {
|
|
270
|
+
expected.params.fields.partitioning_field.stringValue = "created_at";
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("rejects a stored config without params.fields", async () => {
|
|
276
|
+
const stored = unchangedTransferConfig();
|
|
277
|
+
delete stored.params;
|
|
278
|
+
|
|
279
|
+
await expect(
|
|
280
|
+
constructUpdateTransferConfigRequest(
|
|
281
|
+
clientReturning(stored),
|
|
282
|
+
TRANSFER_CONFIG_NAME,
|
|
283
|
+
config
|
|
284
|
+
)
|
|
285
|
+
).rejects.toThrow("missing params.fields");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("getTransferConfig", () => {
|
|
290
|
+
test("returns the transfer config when found", async () => {
|
|
291
|
+
const client = clientReturning(unchangedTransferConfig());
|
|
292
|
+
|
|
293
|
+
const result = await getTransferConfig(client, TRANSFER_CONFIG_NAME);
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual(unchangedTransferConfig());
|
|
296
|
+
expect(client.getTransferConfig).toHaveBeenCalledWith({
|
|
297
|
+
name: TRANSFER_CONFIG_NAME,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("returns null on a gRPC NOT_FOUND", async () => {
|
|
302
|
+
const result = await getTransferConfig(
|
|
303
|
+
clientRejecting(grpcNotFound()),
|
|
304
|
+
TRANSFER_CONFIG_NAME
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(result).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("rethrows any other API failure", async () => {
|
|
311
|
+
await expect(
|
|
312
|
+
getTransferConfig(
|
|
313
|
+
clientRejecting(new Error("API Error")),
|
|
314
|
+
TRANSFER_CONFIG_NAME
|
|
315
|
+
)
|
|
316
|
+
).rejects.toThrow("API Error");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("createTransferConfig", () => {
|
|
321
|
+
test("returns the created transfer config", async () => {
|
|
322
|
+
const created = { name: TRANSFER_CONFIG_NAME };
|
|
323
|
+
const client = clientReturning(null);
|
|
324
|
+
client.createTransferConfig.mockResolvedValue([created]);
|
|
325
|
+
|
|
326
|
+
const result = await createTransferConfig(client, config);
|
|
327
|
+
|
|
328
|
+
expect(result).toEqual(created);
|
|
329
|
+
expect(client.createTransferConfig).toHaveBeenCalledWith(
|
|
330
|
+
expect.objectContaining({ parent: "projects/test-project" })
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("rejects when the API returns a config without a name", async () => {
|
|
335
|
+
const client = clientReturning(null);
|
|
336
|
+
client.createTransferConfig.mockResolvedValue([{}]);
|
|
337
|
+
|
|
338
|
+
await expect(createTransferConfig(client, config)).rejects.toThrow(
|
|
339
|
+
"BigQuery API returned a transfer config without a name"
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe("updateTransferConfig", () => {
|
|
345
|
+
test("returns the updated transfer config", async () => {
|
|
346
|
+
const updated = {
|
|
347
|
+
name: TRANSFER_CONFIG_NAME,
|
|
348
|
+
schedule: "every 15 minutes",
|
|
349
|
+
};
|
|
350
|
+
const client = clientReturning(unchangedTransferConfig());
|
|
351
|
+
client.updateTransferConfig.mockResolvedValue([updated]);
|
|
352
|
+
|
|
353
|
+
const result = await updateTransferConfig(client, TRANSFER_CONFIG_NAME, {
|
|
354
|
+
...config,
|
|
355
|
+
schedule: "every 15 minutes",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(result).toEqual(updated);
|
|
359
|
+
expect(client.updateTransferConfig).toHaveBeenCalledWith(
|
|
360
|
+
UpdateTransferConfigRequest.fromObject({
|
|
361
|
+
transferConfig: transferConfigWithDelta((expected) => {
|
|
362
|
+
expected.schedule = "every 15 minutes";
|
|
363
|
+
}),
|
|
364
|
+
updateMask: { paths: ["schedule"] },
|
|
365
|
+
})
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("still sends the update, with an empty mask, when nothing changed", async () => {
|
|
370
|
+
const client = clientReturning(unchangedTransferConfig());
|
|
371
|
+
client.updateTransferConfig.mockResolvedValue([unchangedTransferConfig()]);
|
|
372
|
+
|
|
373
|
+
await updateTransferConfig(client, TRANSFER_CONFIG_NAME, config);
|
|
374
|
+
|
|
375
|
+
const sent = client.updateTransferConfig.mock.calls[0][0];
|
|
376
|
+
expect(sent.updateMask?.paths).toEqual([]);
|
|
377
|
+
// On the wire the empty path list serializes as an empty FieldMask.
|
|
378
|
+
expect(sent.toJSON().updateMask).toEqual({});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("rejects when the transfer config no longer exists", async () => {
|
|
382
|
+
const client = clientReturning(null);
|
|
383
|
+
|
|
384
|
+
await expect(
|
|
385
|
+
updateTransferConfig(client, TRANSFER_CONFIG_NAME, config)
|
|
386
|
+
).rejects.toThrow("Transfer config not found");
|
|
387
|
+
expect(client.updateTransferConfig).not.toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("rethrows a failure from the update call", async () => {
|
|
391
|
+
const client = clientReturning(unchangedTransferConfig());
|
|
392
|
+
client.updateTransferConfig.mockRejectedValue(
|
|
393
|
+
new Error("Update API Error")
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
await expect(
|
|
397
|
+
updateTransferConfig(client, TRANSFER_CONFIG_NAME, config)
|
|
398
|
+
).rejects.toThrow("Update API Error");
|
|
399
|
+
});
|
|
400
|
+
});
|
package/tests/dts.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
constructUpdateTransferConfigRequest,
|
|
20
20
|
createTransferConfigRequest,
|
|
21
21
|
type DataTransferClient,
|
|
22
|
+
notificationTopicName,
|
|
22
23
|
PARTITIONING_FIELD_REMOVAL_ERROR,
|
|
23
24
|
} from "../src/dts";
|
|
24
25
|
import { resolveConfig } from "../src/export-config";
|
|
@@ -63,6 +64,22 @@ describe("createTransferConfigRequest", () => {
|
|
|
63
64
|
});
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
describe("notificationTopicName", () => {
|
|
68
|
+
test("qualifies a bare topic ID with the project", () => {
|
|
69
|
+
expect(notificationTopicName(config)).toBe(
|
|
70
|
+
"projects/test-project/topics/kit-users-export-processMessages"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes through a topic already given as a resource name", () => {
|
|
75
|
+
const qualified = "projects/other-project/topics/ext-users-processMessages";
|
|
76
|
+
|
|
77
|
+
expect(notificationTopicName({ ...config, pubSubTopic: qualified })).toBe(
|
|
78
|
+
qualified
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
66
83
|
describe("constructUpdateTransferConfigRequest", () => {
|
|
67
84
|
test("deduplicates the params update mask", async () => {
|
|
68
85
|
const client = clientWithTransferConfig({
|
package/tests/handlers.test.ts
CHANGED
|
@@ -23,13 +23,25 @@ const mocks = vi.hoisted(() => ({
|
|
|
23
23
|
getTransferConfig: vi.fn(),
|
|
24
24
|
updateTransferConfig: vi.fn(),
|
|
25
25
|
handleTransferRunMessage: vi.fn(),
|
|
26
|
+
linkedTransferConfigMissing: vi.fn(),
|
|
27
|
+
partitioningFieldRemovalAborted: vi.fn(),
|
|
28
|
+
linkedTopicMismatch: vi.fn(),
|
|
26
29
|
}));
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The error constants come from the real module so the handler's prefix match
|
|
32
|
+
// is tested against the message the guard actually throws.
|
|
33
|
+
vi.mock("../src/dts", async (importOriginal) => {
|
|
34
|
+
const actual = await importOriginal<typeof import("../src/dts")>();
|
|
35
|
+
return {
|
|
36
|
+
createTransferConfig: mocks.createTransferConfig,
|
|
37
|
+
getTransferConfig: mocks.getTransferConfig,
|
|
38
|
+
updateTransferConfig: mocks.updateTransferConfig,
|
|
39
|
+
notificationTopicName: actual.notificationTopicName,
|
|
40
|
+
PARTITIONING_FIELD_REMOVAL_ERROR: actual.PARTITIONING_FIELD_REMOVAL_ERROR,
|
|
41
|
+
PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX:
|
|
42
|
+
actual.PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
33
45
|
|
|
34
46
|
vi.mock("../src/helper", () => ({
|
|
35
47
|
handleTransferRunMessage: mocks.handleTransferRunMessage,
|
|
@@ -41,10 +53,14 @@ vi.mock("../src/helper", () => ({
|
|
|
41
53
|
vi.mock("../src/logs", () => ({
|
|
42
54
|
complete: vi.fn(),
|
|
43
55
|
error: vi.fn(),
|
|
56
|
+
linkedTransferConfigMissing: mocks.linkedTransferConfigMissing,
|
|
57
|
+
partitioningFieldRemovalAborted: mocks.partitioningFieldRemovalAborted,
|
|
44
58
|
start: vi.fn(),
|
|
45
59
|
topicCreated: vi.fn(),
|
|
60
|
+
linkedTopicMismatch: mocks.linkedTopicMismatch,
|
|
46
61
|
}));
|
|
47
62
|
|
|
63
|
+
import { PARTITIONING_FIELD_REMOVAL_ERROR } from "../src/dts";
|
|
48
64
|
import { handleUpsertTransferConfig } from "../src/handlers";
|
|
49
65
|
|
|
50
66
|
const config = resolveConfig({
|
|
@@ -149,6 +165,8 @@ describe("handleUpsertTransferConfig", () => {
|
|
|
149
165
|
test("links an explicitly named transfer config without updating it", async () => {
|
|
150
166
|
const linked = {
|
|
151
167
|
name: "projects/p/locations/us/transferConfigs/config-2",
|
|
168
|
+
notificationPubsubTopic:
|
|
169
|
+
"projects/test-project/topics/kit-users-export-processMessages",
|
|
152
170
|
};
|
|
153
171
|
mocks.getTransferConfig.mockResolvedValue(linked);
|
|
154
172
|
const { ctx, set } = makeContext({
|
|
@@ -162,9 +180,99 @@ describe("handleUpsertTransferConfig", () => {
|
|
|
162
180
|
linked.name
|
|
163
181
|
);
|
|
164
182
|
expect(mocks.updateTransferConfig).not.toHaveBeenCalled();
|
|
183
|
+
expect(mocks.linkedTopicMismatch).not.toHaveBeenCalled();
|
|
165
184
|
expect(set).toHaveBeenCalledWith({
|
|
166
185
|
extInstanceId: "users-export",
|
|
167
186
|
...linked,
|
|
168
187
|
});
|
|
169
188
|
});
|
|
189
|
+
|
|
190
|
+
test("warns instead of repointing a linked config on another topic", async () => {
|
|
191
|
+
const linked = {
|
|
192
|
+
name: "projects/p/locations/us/transferConfigs/config-3",
|
|
193
|
+
notificationPubsubTopic:
|
|
194
|
+
"projects/test-project/topics/ext-users-export-processMessages",
|
|
195
|
+
};
|
|
196
|
+
mocks.getTransferConfig.mockResolvedValue(linked);
|
|
197
|
+
const { ctx, set } = makeContext({ transferConfigName: linked.name });
|
|
198
|
+
|
|
199
|
+
await handleUpsertTransferConfig(ctx);
|
|
200
|
+
|
|
201
|
+
expect(mocks.linkedTopicMismatch).toHaveBeenCalledWith(
|
|
202
|
+
linked.name,
|
|
203
|
+
linked.notificationPubsubTopic,
|
|
204
|
+
"projects/test-project/topics/kit-users-export-processMessages"
|
|
205
|
+
);
|
|
206
|
+
expect(mocks.updateTransferConfig).not.toHaveBeenCalled();
|
|
207
|
+
expect(set).toHaveBeenCalledWith({
|
|
208
|
+
extInstanceId: "users-export",
|
|
209
|
+
...linked,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("reports a missing linked transfer config without retrying", async () => {
|
|
214
|
+
mocks.getTransferConfig.mockResolvedValue(null);
|
|
215
|
+
const { ctx, set } = makeContext({
|
|
216
|
+
transferConfigName: "projects/p/locations/us/transferConfigs/gone",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await expect(handleUpsertTransferConfig(ctx)).resolves.toBeUndefined();
|
|
220
|
+
|
|
221
|
+
expect(mocks.linkedTransferConfigMissing).toHaveBeenCalledWith(
|
|
222
|
+
"projects/p/locations/us/transferConfigs/gone"
|
|
223
|
+
);
|
|
224
|
+
expect(set).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("reports a rejected partitioning-field removal without retrying", async () => {
|
|
228
|
+
mocks.updateTransferConfig.mockRejectedValue(
|
|
229
|
+
new Error(PARTITIONING_FIELD_REMOVAL_ERROR)
|
|
230
|
+
);
|
|
231
|
+
const { ctx, set } = makeContext({
|
|
232
|
+
existing: {
|
|
233
|
+
empty: false,
|
|
234
|
+
docs: [
|
|
235
|
+
{
|
|
236
|
+
data: () => ({
|
|
237
|
+
name: "projects/p/locations/us/transferConfigs/config-1",
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await expect(handleUpsertTransferConfig(ctx)).resolves.toBeUndefined();
|
|
245
|
+
|
|
246
|
+
expect(mocks.partitioningFieldRemovalAborted).toHaveBeenCalledWith(
|
|
247
|
+
PARTITIONING_FIELD_REMOVAL_ERROR
|
|
248
|
+
);
|
|
249
|
+
expect(set).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("rethrows any other update failure so the task retries", async () => {
|
|
253
|
+
mocks.updateTransferConfig.mockRejectedValue(
|
|
254
|
+
new Error(
|
|
255
|
+
"bigquerydatatransfer.googleapis.com is temporarily unavailable"
|
|
256
|
+
)
|
|
257
|
+
);
|
|
258
|
+
const { ctx, set } = makeContext({
|
|
259
|
+
existing: {
|
|
260
|
+
empty: false,
|
|
261
|
+
docs: [
|
|
262
|
+
{
|
|
263
|
+
data: () => ({
|
|
264
|
+
name: "projects/p/locations/us/transferConfigs/config-1",
|
|
265
|
+
}),
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await expect(handleUpsertTransferConfig(ctx)).rejects.toThrow(
|
|
272
|
+
"temporarily unavailable"
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
expect(mocks.partitioningFieldRemovalAborted).not.toHaveBeenCalled();
|
|
276
|
+
expect(set).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
170
278
|
});
|