@firebase-function-kits/bigquery-firestore-export 0.0.2-rc.1 → 0.0.2-rc.3

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/src/dts.ts CHANGED
@@ -61,6 +61,21 @@ function transferConfigFields(config: TransferConfig) {
61
61
  return fields;
62
62
  }
63
63
 
64
+ /**
65
+ * Full resource name of the topic DTS publishes run notifications to.
66
+ *
67
+ * PUB_SUB_TOPIC is validated as a bare topic ID when prompted, but values from
68
+ * a dotenv file reach us unvalidated, and both the Pub/Sub client and the
69
+ * trigger accept a full resource name, so accept one here too.
70
+ */
71
+ export function notificationTopicName(
72
+ config: ResolvedBigqueryFirestoreExportConfig
73
+ ): string {
74
+ return config.pubSubTopic.startsWith("projects/")
75
+ ? config.pubSubTopic
76
+ : `projects/${config.projectId}/topics/${config.pubSubTopic}`;
77
+ }
78
+
64
79
  function stringField(value: string | undefined): { stringValue: string } {
65
80
  return { stringValue: value ?? "" };
66
81
  }
@@ -93,7 +108,7 @@ export function createTransferConfigRequest(
93
108
  },
94
109
  },
95
110
  schedule: config.schedule,
96
- notificationPubsubTopic: `projects/${config.projectId}/topics/${config.pubSubTopic}`,
111
+ notificationPubsubTopic: notificationTopicName(config),
97
112
  },
98
113
  };
99
114
  }
@@ -184,7 +199,7 @@ export async function constructUpdateTransferConfigRequest(
184
199
  updatedConfig.schedule = config.schedule;
185
200
  }
186
201
 
187
- const expectedTopic = `projects/${config.projectId}/topics/${config.pubSubTopic}`;
202
+ const expectedTopic = notificationTopicName(config);
188
203
  if (expectedTopic !== transferConfig.notificationPubsubTopic) {
189
204
  updateMask.push("notification_pubsub_topic");
190
205
  updatedConfig.notificationPubsubTopic = expectedTopic;
package/src/handlers.ts CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  createTransferConfig,
24
24
  type DataTransferClient,
25
25
  getTransferConfig,
26
+ notificationTopicName,
27
+ PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX,
26
28
  updateTransferConfig,
27
29
  } from "./dts";
28
30
  import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
@@ -64,6 +66,13 @@ async function ensureNotificationTopic(ctx: HandlerContext): Promise<void> {
64
66
  }
65
67
  }
66
68
 
69
+ function isPartitioningFieldRemovalError(err: unknown): err is Error {
70
+ return (
71
+ err instanceof Error &&
72
+ err.message.includes(PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX)
73
+ );
74
+ }
75
+
67
76
  async function storeTransferConfig(
68
77
  ctx: HandlerContext,
69
78
  transferConfig: Awaited<ReturnType<typeof getTransferConfig>>
@@ -105,8 +114,19 @@ export async function handleUpsertTransferConfig(
105
114
  ctx.config.transferConfigName
106
115
  );
107
116
  if (!linked) {
108
- throw new Error(
109
- `Transfer config not found: ${ctx.config.transferConfigName}`
117
+ // Only a redeploy with a corrected TRANSFER_CONFIG_NAME can resolve this,
118
+ // so retrying the task cannot help.
119
+ logs.linkedTransferConfigMissing(ctx.config.transferConfigName);
120
+ return;
121
+ }
122
+ // A linked config is adopted as-is, so a topic mismatch is the user's to
123
+ // resolve: rewriting it would repoint a config this deployment did not create.
124
+ const expectedTopic = notificationTopicName(ctx.config);
125
+ if (linked.notificationPubsubTopic !== expectedTopic) {
126
+ logs.linkedTopicMismatch(
127
+ ctx.config.transferConfigName,
128
+ linked.notificationPubsubTopic,
129
+ expectedTopic
110
130
  );
111
131
  }
112
132
  await storeTransferConfig(ctx, linked);
@@ -128,14 +148,21 @@ export async function handleUpsertTransferConfig(
128
148
  const transferConfigName = existing.docs[0].data().name;
129
149
  if (typeof transferConfigName !== "string" || !transferConfigName) {
130
150
  throw new Error(
131
- `Existing transfer config document in ${ctx.config.firestoreCollection} is missing required 'name' field.`
151
+ `Existing transfer config document in ${ctx.config.firestoreCollection} is missing required 'name' field. Delete the document so a new scheduled query is created, then redeploy.`
132
152
  );
133
153
  }
134
154
 
135
- const updated = await updateTransferConfig(
136
- ctx.dataTransfer,
137
- transferConfigName,
138
- ctx.config
139
- );
140
- await storeTransferConfig(ctx, updated);
155
+ try {
156
+ const updated = await updateTransferConfig(
157
+ ctx.dataTransfer,
158
+ transferConfigName,
159
+ ctx.config
160
+ );
161
+ await storeTransferConfig(ctx, updated);
162
+ } catch (err) {
163
+ if (!isPartitioningFieldRemovalError(err)) throw err;
164
+ // The guard rejects the update while building the request, so nothing was
165
+ // sent and a retry hits the same guard.
166
+ logs.partitioningFieldRemovalAborted(err.message);
167
+ }
141
168
  }
package/src/helper.ts CHANGED
@@ -227,6 +227,7 @@ export async function writeRunResultsToFirestore(
227
227
  message.json.destinationDatasetId,
228
228
  tableName
229
229
  );
230
+ logs.writeRunResultsToFirestore(runId);
230
231
  const collection = db.collection(
231
232
  `${config.firestoreCollection}/${transferConfigId}/runs/${runId}/output`
232
233
  );
package/src/logs.ts CHANGED
@@ -70,6 +70,10 @@ export function bigqueryQueryFailed(
70
70
  });
71
71
  }
72
72
 
73
+ export function writeRunResultsToFirestore(runId: string): void {
74
+ logger.debug("Writing BigQuery results to Firestore", { runId });
75
+ }
76
+
73
77
  export function runResultsWrittenToFirestore(
74
78
  runId: string,
75
79
  successCount: number,
@@ -140,6 +144,30 @@ export function partitioningFieldRemovalAttempted(
140
144
  });
141
145
  }
142
146
 
147
+ export function linkedTransferConfigMissing(name: string): void {
148
+ logger.error(
149
+ "The scheduled query named by TRANSFER_CONFIG_NAME does not exist, so nothing was linked. Set it to a scheduled query that exists in this project and redeploy, or clear it to have this deployment create its own.",
150
+ { name }
151
+ );
152
+ }
153
+
154
+ // The reason carries the remediation, and an Error passed as structured data
155
+ // serialises to {}, so it goes in the message.
156
+ export function partitioningFieldRemovalAborted(reason: string): void {
157
+ logger.error(`Stopped without updating the scheduled query. ${reason}`);
158
+ }
159
+
143
160
  export function topicCreated(name: string): void {
144
161
  logger.info("Created Pub/Sub topic for transfer notifications", { name });
145
162
  }
163
+
164
+ export function linkedTopicMismatch(
165
+ name: string,
166
+ linkedTopic: string | null | undefined,
167
+ expectedTopic: string
168
+ ): void {
169
+ logger.warn(
170
+ "Linked transfer config notifies a different Pub/Sub topic, so its runs will not reach this kit. Set PUB_SUB_TOPIC to the linked topic, or point the transfer config at the configured one.",
171
+ { name, linkedTopic: linkedTopic ?? "", expectedTopic }
172
+ );
173
+ }
@@ -23,12 +23,41 @@ afterEach(() => {
23
23
  });
24
24
 
25
25
  describe("CONFIG_EXPRESSIONS", () => {
26
- test("namespaces the Pub/Sub topic with the required instance id", () => {
26
+ test("binds the trigger to the Pub/Sub topic parameter", () => {
27
27
  expect(CONFIG_EXPRESSIONS.pubSubTopic).toBeInstanceOf(Expression);
28
28
  expect((CONFIG_EXPRESSIONS.pubSubTopic as Expression<string>).toCEL()).toBe(
29
- "kit-{{ params.INSTANCE_ID }}-processMessages"
29
+ "{{ params.PUB_SUB_TOPIC }}"
30
30
  );
31
31
  });
32
+
33
+ test("defaults the topic parameter to the instance-namespaced kit topic", () => {
34
+ const spec = (
35
+ CONFIG_EXPRESSIONS.pubSubTopic as unknown as {
36
+ toSpec: () => { default?: string };
37
+ }
38
+ ).toSpec();
39
+ expect(spec.default).toBe("kit-{{ params.INSTANCE_ID }}-processMessages");
40
+ });
41
+
42
+ test("accepts a topic ID but rejects a full resource name", () => {
43
+ const spec = (
44
+ CONFIG_EXPRESSIONS.pubSubTopic as unknown as {
45
+ toSpec: () => { input?: { text?: { validationRegex?: string } } };
46
+ }
47
+ ).toSpec();
48
+ const pattern = spec.input?.text?.validationRegex;
49
+ expect(pattern).toBeTypeOf("string");
50
+ const validate = (value: string) =>
51
+ new RegExp(pattern as string).test(value);
52
+
53
+ expect(validate("ext-users-export-processMessages")).toBe(true);
54
+ expect(validate("kit-users-export-processMessages")).toBe(true);
55
+ expect(
56
+ validate("projects/test-project/topics/ext-users-export-processMessages")
57
+ ).toBe(false);
58
+ expect(validate("")).toBe(false);
59
+ expect(validate("goog-reserved-prefix")).toBe(false);
60
+ });
32
61
  });
33
62
 
34
63
  describe("configFromEnv", () => {
@@ -47,6 +76,8 @@ describe("configFromEnv", () => {
47
76
  vi.stubEnv("COLLECTION_PATH", "transferConfigs");
48
77
  vi.stubEnv("LOG_LEVEL", "info");
49
78
 
79
+ vi.stubEnv("PUB_SUB_TOPIC", "kit-users-export-processMessages");
80
+
50
81
  expect(configFromEnv()).toMatchObject({
51
82
  projectId: "test-project",
52
83
  instanceId: "users-export",
@@ -58,4 +89,25 @@ describe("configFromEnv", () => {
58
89
  logLevel: "info",
59
90
  });
60
91
  });
92
+
93
+ test("passes through a topic pointing at the extension's own topic", () => {
94
+ vi.stubEnv(
95
+ "FIREBASE_CONFIG",
96
+ JSON.stringify({ projectId: "test-project" })
97
+ );
98
+ vi.stubEnv("INSTANCE_ID", "users-export");
99
+ vi.stubEnv("BIGQUERY_DATASET_LOCATION", "EU");
100
+ vi.stubEnv("DATASET_ID", "analytics");
101
+ vi.stubEnv("TABLE_NAME", "users");
102
+ vi.stubEnv("QUERY_STRING", "SELECT * FROM source.users");
103
+ vi.stubEnv("DISPLAY_NAME", "Users export");
104
+ vi.stubEnv("SCHEDULE", "every 24 hours");
105
+ vi.stubEnv("COLLECTION_PATH", "transferConfigs");
106
+ vi.stubEnv("LOG_LEVEL", "info");
107
+ vi.stubEnv("PUB_SUB_TOPIC", "ext-users-export-processMessages");
108
+
109
+ expect(configFromEnv().pubSubTopic).toBe(
110
+ "ext-users-export-processMessages"
111
+ );
112
+ });
61
113
  });
@@ -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({