@firebase-function-kits/bigquery-firestore-export 0.0.2-rc.0 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/README.md +138 -4
  3. package/lib/config.d.ts.map +1 -1
  4. package/lib/config.js +128 -9
  5. package/lib/config.js.map +1 -1
  6. package/lib/dts.d.ts +17 -2
  7. package/lib/dts.d.ts.map +1 -1
  8. package/lib/dts.js +25 -8
  9. package/lib/dts.js.map +1 -1
  10. package/lib/export-config.d.ts +0 -3
  11. package/lib/export-config.d.ts.map +1 -1
  12. package/lib/export-config.js +0 -1
  13. package/lib/export-config.js.map +1 -1
  14. package/lib/handlers.d.ts.map +1 -1
  15. package/lib/handlers.js +26 -4
  16. package/lib/handlers.js.map +1 -1
  17. package/lib/helper.d.ts.map +1 -1
  18. package/lib/helper.js +1 -0
  19. package/lib/helper.js.map +1 -1
  20. package/lib/index.js +1 -1
  21. package/lib/index.js.map +1 -1
  22. package/lib/logs.d.ts +4 -0
  23. package/lib/logs.d.ts.map +1 -1
  24. package/lib/logs.js +18 -0
  25. package/lib/logs.js.map +1 -1
  26. package/lib/metadata.d.ts.map +1 -1
  27. package/npm-shrinkwrap.json +3841 -0
  28. package/package.json +2 -5
  29. package/src/config.ts +142 -9
  30. package/src/dts.ts +27 -9
  31. package/src/export-config.ts +0 -4
  32. package/src/handlers.ts +36 -9
  33. package/src/helper.ts +1 -0
  34. package/src/index.ts +1 -1
  35. package/src/logs.ts +28 -0
  36. package/tests/config.test.ts +54 -2
  37. package/tests/dts-transfer-config.test.ts +400 -0
  38. package/tests/dts.test.ts +18 -5
  39. package/tests/export-config.test.ts +0 -4
  40. package/tests/handlers.test.ts +113 -5
  41. package/tests/helper-values.test.ts +168 -0
  42. package/tests/helper.test.ts +65 -3
  43. package/tests/notification-topic.test.ts +151 -0
  44. package/tests/run-results.test.ts +373 -0
  45. 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";
@@ -43,10 +44,7 @@ function clientWithTransferConfig(transferConfig: object): DataTransferClient {
43
44
 
44
45
  describe("createTransferConfigRequest", () => {
45
46
  test("creates the scheduled-query request", () => {
46
- const request = createTransferConfigRequest(
47
- config,
48
- "runtime@test-project.iam.gserviceaccount.com"
49
- );
47
+ const request = createTransferConfigRequest(config);
50
48
 
51
49
  expect(request).toMatchObject({
52
50
  parent: "projects/test-project",
@@ -57,7 +55,6 @@ describe("createTransferConfigRequest", () => {
57
55
  schedule: "every 24 hours",
58
56
  notificationPubsubTopic:
59
57
  "projects/test-project/topics/kit-users-export-processMessages",
60
- serviceAccountName: "runtime@test-project.iam.gserviceaccount.com",
61
58
  },
62
59
  });
63
60
  expect(
@@ -67,6 +64,22 @@ describe("createTransferConfigRequest", () => {
67
64
  });
68
65
  });
69
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
+
70
83
  describe("constructUpdateTransferConfigRequest", () => {
71
84
  test("deduplicates the params update mask", async () => {
72
85
  const client = clientWithTransferConfig({
@@ -46,16 +46,12 @@ describe("resolveConfig", () => {
46
46
  ...minimal,
47
47
  transferConfigName: " projects/p/locations/us/transferConfigs/c ",
48
48
  partitioningField: " created_at ",
49
- serviceAccount: " runtime@test-project.iam.gserviceaccount.com ",
50
49
  });
51
50
 
52
51
  expect(resolved.transferConfigName).toBe(
53
52
  "projects/p/locations/us/transferConfigs/c"
54
53
  );
55
54
  expect(resolved.partitioningField).toBe("created_at");
56
- expect(resolved.serviceAccount).toBe(
57
- "runtime@test-project.iam.gserviceaccount.com"
58
- );
59
55
  });
60
56
 
61
57
  test.each(["instanceId", "datasetId", "tableName", "queryString"] as const)(
@@ -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
- vi.mock("../src/dts", () => ({
29
- createTransferConfig: mocks.createTransferConfig,
30
- getTransferConfig: mocks.getTransferConfig,
31
- updateTransferConfig: mocks.updateTransferConfig,
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
  });