Skip to content

Commit c1e995e

Browse files
committed
test(NODE-6318): utf runner withTransaction callback propagates errors from operations
1 parent 5565d50 commit c1e995e

File tree

3 files changed

+127
-60
lines changed

3 files changed

+127
-60
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { type FailPoint, TestBuilder, UnifiedTestSuiteBuilder } from '../../tools/utils';
2+
3+
describe('Unified Test Runner', () => {
4+
UnifiedTestSuiteBuilder.describe('withTransaction error propagation')
5+
.runOnRequirement({ topologies: ['replicaset'] })
6+
.createEntities([
7+
{
8+
client: {
9+
id: 'client',
10+
useMultipleMongoses: true,
11+
uriOptions: { appName: 'bob' },
12+
observeEvents: ['commandStartedEvent', 'commandSucceededEvent', 'commandFailedEvent']
13+
}
14+
},
15+
{ database: { id: 'database', client: 'client', databaseName: 'test' } },
16+
{ collection: { id: 'collection', database: 'database', collectionName: 'coll' } },
17+
{ session: { id: 'session', client: 'client' } },
18+
19+
{ client: { id: 'failPointClient', useMultipleMongoses: false } }
20+
])
21+
.test(
22+
TestBuilder.it('should propagation the error to the withTransaction API')
23+
.operation({
24+
name: 'failPoint',
25+
object: 'testRunner',
26+
arguments: {
27+
client: 'failPointClient',
28+
failPoint: {
29+
configureFailPoint: 'failCommand',
30+
mode: { times: 1 },
31+
data: { failCommands: ['insert'], errorCode: 50, appName: 'bob' }
32+
} as FailPoint
33+
}
34+
})
35+
.operation({
36+
name: 'withTransaction',
37+
object: 'session',
38+
arguments: {
39+
callback: [
40+
{
41+
name: 'insertOne',
42+
object: 'collection',
43+
arguments: { session: 'session', document: { _id: 1 } },
44+
expectError: { isClientError: false }
45+
}
46+
]
47+
},
48+
expectError: { isClientError: false }
49+
})
50+
.expectEvents({
51+
client: 'client',
52+
events: [
53+
{
54+
commandStartedEvent: {
55+
commandName: 'insert',
56+
databaseName: 'test',
57+
command: { insert: 'coll' }
58+
}
59+
},
60+
{ commandFailedEvent: { commandName: 'insert' } },
61+
{
62+
commandStartedEvent: {
63+
commandName: 'abortTransaction',
64+
databaseName: 'admin',
65+
command: { abortTransaction: 1 }
66+
}
67+
},
68+
{ commandFailedEvent: { commandName: 'abortTransaction' } }
69+
]
70+
})
71+
.toJSON()
72+
)
73+
.run();
74+
});

test/tools/unified-spec-runner/operations.ts

Lines changed: 52 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-unused-vars */
21
/* eslint-disable @typescript-eslint/no-non-null-assertion */
32

43
import { Readable } from 'node:stream';
@@ -7,16 +6,13 @@ import { pipeline } from 'node:stream/promises';
76
import { AssertionError, expect } from 'chai';
87

98
import {
10-
AbstractCursor,
119
type ChangeStream,
1210
Collection,
1311
CommandStartedEvent,
1412
Db,
1513
type Document,
16-
type GridFSFile,
1714
type MongoClient,
1815
MongoError,
19-
type ObjectId,
2016
ReadConcern,
2117
ReadPreference,
2218
SERVER_DESCRIPTION_CHANGED,
@@ -25,7 +21,7 @@ import {
2521
type TopologyType,
2622
WriteConcern
2723
} from '../../mongodb';
28-
import { getSymbolFrom, sleep } from '../../tools/utils';
24+
import { sleep } from '../../tools/utils';
2925
import { type TestConfiguration } from '../runner/config';
3026
import { EntitiesMap } from './entities';
3127
import { expectErrorCheck, resultCheck } from './match';
@@ -44,11 +40,9 @@ type RunOperationFn = (
4440
) => Promise<Document | boolean | number | null | void | string>;
4541
export const operations = new Map<string, RunOperationFn>();
4642

47-
export class MalformedOperationError extends AssertionError {}
48-
4943
operations.set('createEntities', async ({ entities, operation, testConfig }) => {
5044
if (!operation.arguments?.entities) {
51-
throw new AssertionError('encountered createEntities operation without entities argument');
45+
expect.fail('encountered createEntities operation without entities argument');
5246
}
5347
await EntitiesMap.createEntities(testConfig, null, operation.arguments.entities!, entities);
5448
});
@@ -61,7 +55,7 @@ operations.set('abortTransaction', async ({ entities, operation }) => {
6155
operations.set('aggregate', async ({ entities, operation }) => {
6256
const dbOrCollection = entities.get(operation.object) as Db | Collection;
6357
if (!(dbOrCollection instanceof Db || dbOrCollection instanceof Collection)) {
64-
throw new AssertionError(`Operation object '${operation.object}' must be a db or collection`);
58+
expect.fail(`Operation object '${operation.object}' must be a db or collection`);
6559
}
6660
const { pipeline, ...opts } = operation.arguments!;
6761
const cursor = dbOrCollection.aggregate(pipeline, opts);
@@ -153,27 +147,27 @@ operations.set('assertSameLsidOnLastTwoCommands', async ({ entities, operation }
153147
expect(last.command.lsid.id.buffer.equals(secondLast.command.lsid.id.buffer)).to.be.true;
154148
});
155149

156-
operations.set('assertSessionDirty', async ({ entities, operation }) => {
150+
operations.set('assertSessionDirty', async ({ operation }) => {
157151
const session = operation.arguments!.session;
158152
expect(session.serverSession.isDirty).to.be.true;
159153
});
160154

161-
operations.set('assertSessionNotDirty', async ({ entities, operation }) => {
155+
operations.set('assertSessionNotDirty', async ({ operation }) => {
162156
const session = operation.arguments!.session;
163157
expect(session.serverSession.isDirty).to.be.false;
164158
});
165159

166-
operations.set('assertSessionPinned', async ({ entities, operation }) => {
160+
operations.set('assertSessionPinned', async ({ operation }) => {
167161
const session = operation.arguments!.session;
168162
expect(session.isPinned, 'session should be pinned').to.be.true;
169163
});
170164

171-
operations.set('assertSessionUnpinned', async ({ entities, operation }) => {
165+
operations.set('assertSessionUnpinned', async ({ operation }) => {
172166
const session = operation.arguments!.session;
173167
expect(session.isPinned, 'session should be unpinned').to.be.false;
174168
});
175169

176-
operations.set('assertSessionTransactionState', async ({ entities, operation }) => {
170+
operations.set('assertSessionTransactionState', async ({ operation }) => {
177171
const session = operation.arguments!.session;
178172

179173
const transactionStateTranslation = {
@@ -230,7 +224,7 @@ operations.set('close', async ({ entities, operation }) => {
230224
} catch {}
231225
/* eslint-enable no-empty */
232226

233-
throw new AssertionError(`No closable entity with key ${operation.object}`);
227+
expect.fail(`No closable entity with key ${operation.object}`);
234228
});
235229

236230
operations.set('commitTransaction', async ({ entities, operation }) => {
@@ -240,8 +234,8 @@ operations.set('commitTransaction', async ({ entities, operation }) => {
240234

241235
operations.set('createChangeStream', async ({ entities, operation }) => {
242236
const watchable = entities.get(operation.object);
243-
if (watchable == null || !('watch' in watchable)) {
244-
throw new AssertionError(`Entity ${operation.object} must be watchable`);
237+
if (watchable == null || typeof watchable !== 'object' || !('watch' in watchable)) {
238+
expect.fail(`Entity ${operation.object} must be watchable`);
245239
}
246240

247241
const { pipeline, ...args } = operation.arguments!;
@@ -292,7 +286,7 @@ operations.set('dropCollection', async ({ entities, operation }) => {
292286

293287
// TODO(NODE-4243): dropCollection should suppress namespace not found errors
294288
try {
295-
return await db.dropCollection(collection, opts);
289+
await db.dropCollection(collection, opts);
296290
} catch (err) {
297291
if (!/ns not found/.test(err.message)) {
298292
throw err;
@@ -359,7 +353,7 @@ operations.set('insertOne', async ({ entities, operation }) => {
359353
const collection = entities.getEntity('collection', operation.object);
360354
const { document, ...opts } = operation.arguments!;
361355
if (!document) {
362-
throw new MalformedOperationError('No document defined in the arguments for insertOne');
356+
expect.fail('No document defined in the arguments for insertOne');
363357
}
364358
// Looping exposes the fact that we can generate _ids for inserted
365359
// documents and we don't want the original operation to get modified
@@ -544,10 +538,10 @@ operations.set('upload', async ({ entities, operation }) => {
544538
const bucket = entities.getEntity('bucket', operation.object);
545539
const { filename, source, ...options } = operation.arguments ?? {};
546540

547-
const stream = bucket.openUploadStream(operation.arguments!.filename, options);
548-
const filestream = Readable.from(Buffer.from(operation.arguments!.source.$$hexBytes, 'hex'));
541+
const stream = bucket.openUploadStream(filename, options);
542+
const fileStream = Readable.from(Buffer.from(source.$$hexBytes, 'hex'));
549543

550-
await pipeline(filestream, stream);
544+
await pipeline(fileStream, stream);
551545
return stream.gridFSFile?._id;
552546
});
553547

@@ -586,13 +580,11 @@ operations.set('waitForEvent', async ({ entities, operation }) => {
586580
await Promise.race([
587581
eventPromise,
588582
sleep(10000).then(() =>
589-
Promise.reject(
590-
new AssertionError(
591-
`Timed out waiting for ${eventName}; captured [${mongoClient
592-
.getCapturedEvents('all')
593-
.map(e => e.constructor.name)
594-
.join(', ')}]`
595-
)
583+
expect.fail(
584+
`Timed out waiting for ${eventName}; captured [${mongoClient
585+
.getCapturedEvents('all')
586+
.map(e => e.constructor.name)
587+
.join(', ')}]`
596588
)
597589
)
598590
]);
@@ -682,7 +674,7 @@ operations.set('waitForPrimaryChange', async ({ entities, operation }) => {
682674
await Promise.race([
683675
newPrimaryPromise,
684676
sleep(timeoutMS ?? 10000).then(() =>
685-
Promise.reject(new AssertionError(`Timed out waiting for primary change on ${client}`))
677+
expect.fail(`Timed out waiting for primary change on ${client}`)
686678
)
687679
]);
688680
});
@@ -702,9 +694,7 @@ operations.set('waitForThread', async ({ entities, operation }) => {
702694
const thread = entities.getEntity('thread', threadId, true);
703695
await Promise.race([
704696
thread.finish(),
705-
sleep(10000).then(() =>
706-
Promise.reject(new AssertionError(`Timed out waiting for thread: ${threadId}`))
707-
)
697+
sleep(10000).then(() => expect.fail(`Timed out waiting for thread: ${threadId}`))
708698
]);
709699
});
710700

@@ -720,7 +710,7 @@ operations.set('withTransaction', async ({ entities, operation, client, testConf
720710

721711
await session.withTransaction(async () => {
722712
for (const callbackOperation of operation.arguments!.callback) {
723-
await executeOperationAndCheck(callbackOperation, entities, client, testConfig);
713+
await executeOperationAndCheck(callbackOperation, entities, client, testConfig, true);
724714
}
725715
}, options);
726716
});
@@ -757,11 +747,10 @@ operations.set('estimatedDocumentCount', async ({ entities, operation }) => {
757747
operations.set('runCommand', async ({ entities, operation }: OperationFunctionParams) => {
758748
const db = entities.getEntity('db', operation.object);
759749

760-
if (operation.arguments?.command == null)
761-
throw new AssertionError('runCommand requires a command');
750+
if (operation.arguments?.command == null) expect.fail('runCommand requires a command');
762751
const { command } = operation.arguments;
763752

764-
if (operation.arguments.timeoutMS != null) throw new AssertionError('timeoutMS not supported');
753+
if (operation.arguments.timeoutMS != null) expect.fail('timeoutMS not supported');
765754

766755
const options = {
767756
readPreference: operation.arguments.readPreference,
@@ -931,14 +920,14 @@ operations.set('modifyCollection', async ({ entities, operation }) => {
931920
return db.command(command, {});
932921
});
933922

934-
export async function executeOperationAndCheck(
923+
export async function executeTestOperation(
935924
operation: OperationDescription,
936925
entities: EntitiesMap,
937926
client: MongoClient,
938927
testConfig: TestConfiguration
939-
): Promise<void> {
928+
): Promise<{ result: unknown; success: true } | { result: Error; success: false }> {
940929
const opFunc = operations.get(operation.name);
941-
expect(opFunc, `Unknown operation: ${operation.name}`).to.exist;
930+
if (opFunc == null) expect.fail(`Unknown operation: ${operation.name}`);
942931

943932
if (operation.arguments && operation.arguments.session) {
944933
// The session could need to be either pulled from the entity map or in the case where
@@ -949,33 +938,36 @@ export async function executeOperationAndCheck(
949938
}
950939
}
951940

952-
let result;
953-
954941
try {
955-
result = await opFunc!({ entities, operation, client, testConfig });
956-
} catch (error) {
957-
if (operation.expectError) {
958-
expectErrorCheck(error, operation.expectError, entities);
959-
return;
960-
} else if (!operation.ignoreResultAndError || error instanceof MalformedOperationError) {
961-
throw error;
962-
}
942+
const result = await opFunc({ entities, operation, client, testConfig });
943+
return { result, success: true };
944+
} catch (result) {
945+
return { result, success: false };
963946
}
947+
}
964948

965-
// We check the positive outcome here so the try-catch above doesn't catch our chai assertions
966-
if (operation.ignoreResultAndError) {
967-
return;
968-
}
949+
export async function executeOperationAndCheck(
950+
operation: OperationDescription,
951+
entities: EntitiesMap,
952+
client: MongoClient,
953+
testConfig: TestConfiguration,
954+
rethrow = false
955+
): Promise<void> {
956+
const outcome = await executeTestOperation(operation, entities, client, testConfig);
957+
958+
if (operation.saveResultAsEntity) entities.set(operation.saveResultAsEntity, outcome.result);
959+
960+
if (operation.ignoreResultAndError) return;
969961

970962
if (operation.expectError) {
971-
expect.fail(`Operation ${operation.name} succeeded but was not supposed to`);
963+
if (outcome.success === true) expect.fail(`${operation.name} unexpectedly succeeded`);
964+
expectErrorCheck(outcome.result, operation.expectError, entities);
965+
if (rethrow) throw outcome.result;
966+
return;
972967
}
973968

974969
if (operation.expectResult) {
975-
resultCheck(result, operation.expectResult as any, entities);
976-
}
977-
978-
if (operation.saveResultAsEntity) {
979-
entities.set(operation.saveResultAsEntity, result);
970+
if (outcome.success === false) expect.fail(`${operation.name} unexpectedly failed`);
971+
return resultCheck(outcome.result, operation.expectResult as any, entities);
980972
}
981973
}

test/tools/unified-spec-runner/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ export interface StoreEventsAsEntity {
386386
}
387387
export interface ExpectedError {
388388
isError?: true;
389+
isTimeoutError?: boolean;
389390
isClientError?: boolean;
390391
errorContains?: string;
391392
errorCode?: number;

0 commit comments

Comments
 (0)