diff --git a/CHANGELOG.md b/CHANGELOG.md index e216943..e54ea46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- matcher `toHaveReceivedCommandExactlyOnceWith` can be used to verify there are + no additional calls + ### Changed - Update dependencies. This bumps `@vitest/expect` dependency to `^3.0.1` diff --git a/README.md b/README.md index 3a28c39..600adff 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ You must register the new matchers explicity (think about putting this to a [set }, }); - to add the custom mat chers before each test run + to add the custom matchers before each test run */ import { expect } from "vitest"; import { @@ -57,6 +57,8 @@ import { toHaveReceivedLastCommandWith, toReceiveAnyCommand, toHaveReceivedAnyCommand, + toReceiveCommandExactlyOnceWith, + toHaveReceivedCommandExactlyOnceWith, } from "aws-sdk-client-mock-vitest"; expect.extend({ @@ -74,6 +76,8 @@ expect.extend({ toHaveReceivedLastCommandWith, toReceiveAnyCommand, toHaveReceivedAnyCommand, + toReceiveCommandExactlyOnceWith, + toHaveReceivedCommandExactlyOnceWith, }); ``` diff --git a/src/index.ts b/src/index.ts index 27eca9a..571f90d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export type { CustomMatcher } from './matcher.js'; export { toHaveReceivedAnyCommand, toHaveReceivedCommand, + toHaveReceivedCommandExactlyOnceWith, toHaveReceivedCommandOnce, toHaveReceivedCommandTimes, toHaveReceivedCommandWith, @@ -9,6 +10,7 @@ export { toHaveReceivedNthCommandWith, toReceiveAnyCommand, toReceiveCommand, + toReceiveCommandExactlyOnceWith, toReceiveCommandOnce, toReceiveCommandTimes, toReceiveCommandWith, diff --git a/src/matcher.ts b/src/matcher.ts index 404cf03..e92dfc7 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -15,6 +15,7 @@ import { notNull, ordinalOf } from './utils.js'; interface AliasMatcher { toReceiveAnyCommand: BaseMatcher['toHaveReceivedAnyCommand']; toReceiveCommand: BaseMatcher['toHaveReceivedCommand']; + toReceiveCommandExactlyOnceWith: BaseMatcher['toHaveReceivedCommandExactlyOnceWith']; toReceiveCommandOnce: BaseMatcher['toHaveReceivedCommandOnce']; toReceiveCommandTimes: BaseMatcher['toHaveReceivedCommandTimes']; toReceiveCommandWith: BaseMatcher['toHaveReceivedCommandWith']; @@ -39,6 +40,14 @@ interface BaseMatcher { command: AwsCommandConstructur ): R; + toHaveReceivedCommandExactlyOnceWith< + Input extends object, + Output extends MetadataBearer, + >( + command: AwsCommandConstructur, + input: Partial + ): R; + toHaveReceivedCommandOnce< Input extends object, Output extends MetadataBearer, @@ -209,9 +218,34 @@ function toHaveReceivedCommandWith( }; }; const toReceiveCommandWith = toHaveReceivedCommandWith; -/* - */ +function toHaveReceivedCommandExactlyOnceWith( + this: MatcherState, + client: AwsStub, + command: AwsCommandConstructur, + input: Record, +): ExpectationResult { + const { isNot, utils } = this; + const calls = client.commandCalls(command); + + const hasCallWithArgs = calls.some(call => + new ObjectContaining(input).asymmetricMatch(call.args[0].input), + ); + + const pass = calls.length === 1 && hasCallWithArgs; + + return { + message: () => { + const message = isNot + ? `expected "${command.name}" to not be called once with arguments: ${utils.printExpected(input)}` + : `expected "${command.name}" to be called once with arguments: ${utils.printExpected(input)}`; + return formatCalls(this, client, command, input, message); + }, + pass, + }; +}; +const toReceiveCommandExactlyOnceWith = toHaveReceivedCommandExactlyOnceWith; + function toHaveReceivedNthCommandWith( this: MatcherState, client: AwsStub, @@ -289,6 +323,7 @@ export type { CustomMatcher }; export { toHaveReceivedAnyCommand, toHaveReceivedCommand, + toHaveReceivedCommandExactlyOnceWith, toHaveReceivedCommandOnce, toHaveReceivedCommandTimes, toHaveReceivedCommandWith, @@ -296,6 +331,7 @@ export { toHaveReceivedNthCommandWith, toReceiveAnyCommand, toReceiveCommand, + toReceiveCommandExactlyOnceWith, toReceiveCommandOnce, toReceiveCommandTimes, toReceiveCommandWith, diff --git a/tests/index.test.ts b/tests/index.test.ts index fd94e3a..55524cc 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { toHaveReceivedAnyCommand, toHaveReceivedCommand, + toHaveReceivedCommandExactlyOnceWith, toHaveReceivedCommandOnce, toHaveReceivedCommandTimes, toHaveReceivedCommandWith, @@ -12,6 +13,7 @@ import { toHaveReceivedNthCommandWith, toReceiveAnyCommand, toReceiveCommand, + toReceiveCommandExactlyOnceWith, toReceiveCommandOnce, toReceiveCommandTimes, toReceiveCommandWith, @@ -22,6 +24,7 @@ import { expect.extend({ toHaveReceivedAnyCommand, toHaveReceivedCommand, + toHaveReceivedCommandExactlyOnceWith, toHaveReceivedCommandOnce, toHaveReceivedCommandTimes, toHaveReceivedCommandWith, @@ -29,6 +32,7 @@ expect.extend({ toHaveReceivedNthCommandWith, toReceiveAnyCommand, toReceiveCommand, + toReceiveCommandExactlyOnceWith, toReceiveCommandOnce, toReceiveCommandTimes, toReceiveCommandWith, @@ -52,6 +56,8 @@ describe('aws-sdk-client-mock-vitest', () => { 'toReceiveLastCommandWith', 'toReceiveNthCommandWith', 'toReceiveAnyCommand', + 'toReceiveCommandExactlyOnceWith', + 'toHaveReceivedCommandExactlyOnceWith', ])('extend matcher to extend with %s', (matcher) => { expect(expect('something')).toHaveProperty(matcher); }); diff --git a/tests/matcher.test.ts b/tests/matcher.test.ts index f25083b..0e328a3 100644 --- a/tests/matcher.test.ts +++ b/tests/matcher.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'; import { toHaveReceivedAnyCommand, toHaveReceivedCommand, + toHaveReceivedCommandExactlyOnceWith, toHaveReceivedCommandOnce, toHaveReceivedCommandTimes, toHaveReceivedCommandWith, @@ -19,6 +20,7 @@ import { toHaveReceivedNthCommandWith, toReceiveAnyCommand, toReceiveCommand, + toReceiveCommandExactlyOnceWith, toReceiveCommandOnce, toReceiveCommandTimes, toReceiveCommandWith, @@ -29,6 +31,7 @@ import { expect.extend({ toHaveReceivedAnyCommand, toHaveReceivedCommand, + toHaveReceivedCommandExactlyOnceWith, toHaveReceivedCommandOnce, toHaveReceivedCommandTimes, toHaveReceivedCommandWith, @@ -36,6 +39,7 @@ expect.extend({ toHaveReceivedNthCommandWith, toReceiveAnyCommand, toReceiveCommand, + toReceiveCommandExactlyOnceWith, toReceiveCommandOnce, toReceiveCommandTimes, toReceiveCommandWith, @@ -1478,3 +1482,401 @@ describe('toHaveReceivedAnyCommand', () => { }); }); }); + +describe('toHaveReceivedCommandExactlyOnceWith', () => { + it('passes if called exactly once with command', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }); + + it('passes if called exactly once with partial command', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + }); + }); + + it('passes with a correct asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.txt$/) as string, + }); + }); + + it('fails when not called', () => { + const s3Mock = mockClient(S3Client); + + expect(() => { + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bucket2', + Key: 'key2', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails when received with wrong command', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }).toThrow(/expected "PutObjectCommand" to be called once with arguments/); + }); + + it('fails when input does not match', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'wrongkey.txt', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails when input misses fields', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + VersionId: '10', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails on failed asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(() => { + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.jpg/) as string, + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails when received to often', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test2.txt' })); + expect(() => { + expect(s3Mock).toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + describe('not', () => { + it('passes when never called', () => { + const s3Mock = mockClient(S3Client); + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }); + + it('passes when not called with input', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bar', + Key: 'test.txt', + }); + }); + + it('passes when called multiple times', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'bar', Key: 'test.txt' })); + await s3.send(new GetObjectCommand({ Bucket: 'baz', Key: 'test.txt' })); + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bar', + Key: 'test.txt', + }); + }); + + it('fails on partial match', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'bar', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bar', + }); + }).toThrow(/expected "GetObjectCommand" to not be called once with arguments/); + }); + + it('fails on correct asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(() => { + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.txt$/) as string, + }); + }).toThrow(/expected "GetObjectCommand" to not be called once with arguments/); + }); + + it('passes when called with additional arguments', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + VersionId: 'abc', + }); + }); + + it('passes on incorrect asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(s3Mock).not.toHaveReceivedCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.jpg/) as string, + }); + }); + }); +}); + +describe('toReceiveCommandExactlyOnceWith', () => { + it('passes if called exactly once with command', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }); + + it('passes if called exactly once with partial command', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + }); + }); + + it('passes with a correct asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.txt$/) as string, + }); + }); + + it('fails when not called', () => { + const s3Mock = mockClient(S3Client); + + expect(() => { + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bucket2', + Key: 'key2', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails when received with wrong command', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).toReceiveCommandExactlyOnceWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }).toThrow(/expected "PutObjectCommand" to be called once with arguments/); + }); + + it('fails when input does not match', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'wrongkey.txt', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails when input misses fields', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + VersionId: '10', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails on failed asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(() => { + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.jpg/) as string, + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + it('fails when received to often', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test2.txt' })); + expect(() => { + expect(s3Mock).toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }).toThrow(/expected "GetObjectCommand" to be called once with arguments/); + }); + + describe('not', () => { + it('passes when never called', () => { + const s3Mock = mockClient(S3Client); + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + }); + }); + + it('passes when not called with input', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bar', + Key: 'test.txt', + }); + }); + + it('passes when called multiple times', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'bar', Key: 'test.txt' })); + await s3.send(new GetObjectCommand({ Bucket: 'baz', Key: 'test.txt' })); + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bar', + Key: 'test.txt', + }); + }); + + it('fails on partial match', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'bar', Key: 'test.txt' })); + expect(() => { + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'bar', + }); + }).toThrow(/expected "GetObjectCommand" to not be called once with arguments/); + }); + + it('fails on correct asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(() => { + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.txt$/) as string, + }); + }).toThrow(/expected "GetObjectCommand" to not be called once with arguments/); + }); + + it('passes when called with additional arguments', async () => { + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: 'test.txt' })); + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: 'test.txt', + VersionId: 'abc', + }); + }); + + it('passes on incorrect asymmetric match', async () => { + // Assume code that uses a random string for the bucket key with a known extension + const name = randomUUID().toString(); + const s3Mock = mockClient(S3Client); + const s3 = new S3Client({}); + await s3.send(new GetObjectCommand({ Bucket: 'foo', Key: `${name}.txt` })); + + // asymmetric matchers like stringMatching are typed as `any` so we cast them + // as we want to compare them to strings + expect(s3Mock).not.toReceiveCommandExactlyOnceWith(GetObjectCommand, { + Bucket: 'foo', + Key: expect.stringMatching(/.jpg/) as string, + }); + }); + }); +});