Skip to content

Commit dd476c6

Browse files
authored
Merge pull request #704 from sid88in/chore/cfn-synthesis-tests
test: add CFN synthesis tests covering 24 plugin scenarios
2 parents 1c6344a + 7e6225c commit dd476c6

99 files changed

Lines changed: 2788 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ jobs:
1717
matrix:
1818
node: [20, 22, 24, 26]
1919
steps:
20-
- uses: actions/setup-node@v5
20+
- uses: actions/setup-node@v6
2121
with:
2222
node-version: ${{ matrix.node }}
2323
- name: Checkout code
24-
uses: actions/checkout@v5
24+
uses: actions/checkout@v6
2525
- name: Install dependencies
2626
run: npm ci
2727
- name: Lint
@@ -42,3 +42,18 @@ jobs:
4242
echo "Error: lib directory is empty"
4343
exit 1
4444
fi
45+
46+
e2e:
47+
name: CFN Synthesis Tests
48+
runs-on: ubuntu-latest
49+
needs: tests
50+
steps:
51+
- name: Checkout code
52+
uses: actions/checkout@v6
53+
- uses: actions/setup-node@v6
54+
with:
55+
node-version: 22
56+
- name: Install dependencies
57+
run: npm ci
58+
- name: Run CFN synthesis tests
59+
run: npm run test:e2e

e2e/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# CFN Synthesis Tests
2+
3+
These tests verify that `serverless-appsync-plugin` generates the
4+
expected CloudFormation when applied to a variety of real-world
5+
configurations. They complement the unit tests in `src/__tests__/`:
6+
7+
| Concern | Unit tests | CFN synthesis tests |
8+
| --------------------------------------------- | ------------------- | ------------------- |
9+
| Pure function logic || |
10+
| Schema validation || |
11+
| Type coercion || |
12+
| Plugin lifecycle on a real `serverless.yml` | ||
13+
| Generated CloudFormation resources | partial (snapshots) ||
14+
| Feature combinations | ||
15+
| Example projects stay current with the plugin | ||
16+
17+
## How it works
18+
19+
Each test loads one of the example projects under [`../examples/`](../examples),
20+
runs `serverless package` to produce a CloudFormation template, and
21+
asserts on the generated resources.
22+
23+
Critically, **these tests do not deploy anything to AWS** — they only
24+
exercise the synthesis path. That makes them fast (~3s per fixture),
25+
fork-safe (no AWS credentials required), and suitable to run on every
26+
PR.
27+
28+
## Running locally
29+
30+
```bash
31+
# Run all CFN synthesis tests
32+
npm run test:e2e
33+
34+
# Run everything (unit + synthesis)
35+
npm run test:all
36+
37+
# Run a single fixture
38+
npx jest --config jest.e2e.config.ts basic-api-key
39+
```
40+
41+
## Adding a new test
42+
43+
1. Create a new example under `examples/<feature>/`:
44+
- `serverless.yml` — minimal configuration demonstrating the feature
45+
- `schema.graphql` — GraphQL schema (or multiple `.graphql` files)
46+
- Any handlers or resolver code the example needs
47+
2. Add an entry to [`../examples/README.md`](../examples/README.md)
48+
3. Create `e2e/<feature>.e2e.test.ts` using the helpers from `helpers/`
49+
4. Run `npm run test:e2e` to verify
50+
51+
The example should be **runnable**: a user should be able to `cd` into
52+
it, run `serverless deploy` (with AWS credentials), and get a working
53+
AppSync API. That keeps the examples honest as documentation.
54+
55+
## Helpers reference
56+
57+
See `helpers/synthesize.ts` and `helpers/assertions.ts` for the full
58+
set of utilities. The common patterns:
59+
60+
```typescript
61+
import { synthesize } from './helpers/synthesize';
62+
import {
63+
expectAuthenticationType,
64+
expectDataSourceOfType,
65+
expectResourceWithProperties,
66+
findOneResourceByType,
67+
getGraphQlApi,
68+
} from './helpers/assertions';
69+
70+
describe('examples/my-feature', () => {
71+
let result: ReturnType<typeof synthesize>;
72+
73+
beforeAll(() => {
74+
result = synthesize('examples/my-feature');
75+
});
76+
77+
afterAll(() => {
78+
result.cleanup();
79+
});
80+
81+
it('does the thing', () => {
82+
expectAuthenticationType(result.template, 'API_KEY');
83+
});
84+
});
85+
```
86+
87+
## CI
88+
89+
A dedicated `e2e` job in `.github/workflows/ci.yml` runs the full
90+
synthesis test suite on every PR and push to `master` or `alpha`. It
91+
runs after the unit-test matrix completes successfully (no point
92+
running E2E if unit tests already failed).

e2e/api-keys-multiple.e2e.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import {
3+
countResourcesByType,
4+
findResourcesByType,
5+
} from './helpers/assertions';
6+
7+
describe('examples/api-keys-multiple', () => {
8+
let result: ReturnType<typeof synthesize>;
9+
10+
beforeAll(() => {
11+
result = synthesize('examples/api-keys-multiple');
12+
});
13+
14+
afterAll(() => {
15+
result.cleanup();
16+
});
17+
18+
it('creates three distinct API key resources', () => {
19+
expect(countResourcesByType(result.template, 'AWS::AppSync::ApiKey')).toBe(
20+
3,
21+
);
22+
});
23+
24+
it('each API key has its own description matching the config', () => {
25+
const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey');
26+
const descriptions = keys
27+
.map((k) => k.resource.Properties?.Description as string)
28+
.sort();
29+
expect(descriptions).toEqual([
30+
'Internal testing',
31+
'Mobile app key',
32+
'Web app key',
33+
]);
34+
});
35+
36+
it('each API key has an expiry set', () => {
37+
const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey');
38+
keys.forEach((k) => {
39+
// The plugin computes an absolute Unix timestamp for Expires
40+
const expires = k.resource.Properties?.Expires;
41+
expect(expires).toBeDefined();
42+
expect(typeof expires).toBe('number');
43+
});
44+
});
45+
});

e2e/basic-api-key.e2e.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import {
3+
countResourcesByType,
4+
expectAuthenticationType,
5+
expectDataSourceOfType,
6+
expectResourceWithProperties,
7+
findOneResourceByType,
8+
getGraphQlApi,
9+
} from './helpers/assertions';
10+
11+
describe('examples/basic-api-key', () => {
12+
let result: ReturnType<typeof synthesize>;
13+
14+
beforeAll(() => {
15+
result = synthesize('examples/basic-api-key');
16+
});
17+
18+
afterAll(() => {
19+
result.cleanup();
20+
});
21+
22+
it('produces a GraphQLApi with API_KEY authentication', () => {
23+
const { resource } = getGraphQlApi(result.template);
24+
expect(resource.Properties?.Name).toBe('basic-api-key');
25+
expectAuthenticationType(result.template, 'API_KEY');
26+
});
27+
28+
it('creates a default API key with the configured description', () => {
29+
expectResourceWithProperties(result.template, 'AWS::AppSync::ApiKey', {
30+
Description: 'Default API key',
31+
});
32+
});
33+
34+
it('creates a GraphQLSchema bound to the API', () => {
35+
findOneResourceByType(result.template, 'AWS::AppSync::GraphQLSchema');
36+
});
37+
38+
it('creates the DynamoDB data source with an IAM role', () => {
39+
const ds = expectDataSourceOfType(result.template, 'AMAZON_DYNAMODB');
40+
expect(ds.resource.Properties?.Name).toBe('users');
41+
// Each AppSync data source needs an IAM role so AppSync can read/write
42+
expect(
43+
countResourcesByType(result.template, 'AWS::IAM::Role'),
44+
).toBeGreaterThan(0);
45+
});
46+
47+
it('creates the Query.getUser resolver wired to the data source', () => {
48+
expectResourceWithProperties(result.template, 'AWS::AppSync::Resolver', {
49+
TypeName: 'Query',
50+
FieldName: 'getUser',
51+
Kind: 'UNIT',
52+
});
53+
});
54+
55+
it('does not create any additional auth provider resources', () => {
56+
// API_KEY only — no Cognito, no Lambda authorizer
57+
const api = getGraphQlApi(result.template);
58+
expect(
59+
api.resource.Properties?.AdditionalAuthenticationProviders,
60+
).toBeUndefined();
61+
});
62+
});

e2e/caching.e2e.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import {
3+
findOneResourceByType,
4+
findResourcesByType,
5+
} from './helpers/assertions';
6+
7+
describe('examples/caching', () => {
8+
let result: ReturnType<typeof synthesize>;
9+
10+
beforeAll(() => {
11+
result = synthesize('examples/caching');
12+
});
13+
14+
afterAll(() => {
15+
result.cleanup();
16+
});
17+
18+
it('creates an ApiCache resource', () => {
19+
const { resource } = findOneResourceByType(
20+
result.template,
21+
'AWS::AppSync::ApiCache',
22+
);
23+
const props = resource.Properties as Record<string, unknown>;
24+
expect(props.ApiCachingBehavior).toBe('PER_RESOLVER_CACHING');
25+
expect(props.Type).toBe('SMALL');
26+
expect(props.Ttl).toBe(600);
27+
expect(props.AtRestEncryptionEnabled).toBe(true);
28+
expect(props.TransitEncryptionEnabled).toBe(true);
29+
});
30+
31+
it('configures caching keys on the resolver', () => {
32+
const resolvers = findResourcesByType(
33+
result.template,
34+
'AWS::AppSync::Resolver',
35+
);
36+
expect(resolvers).toHaveLength(1);
37+
const props = resolvers[0].resource.Properties as Record<string, unknown>;
38+
const cacheConfig = props.CachingConfig as Record<string, unknown>;
39+
expect(cacheConfig).toBeDefined();
40+
expect(cacheConfig.Ttl).toBe(60);
41+
expect(cacheConfig.CachingKeys).toEqual([
42+
'$context.identity.username',
43+
'$context.arguments.id',
44+
]);
45+
});
46+
});

e2e/cognito-userpools.e2e.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import { expectAuthenticationType, getGraphQlApi } from './helpers/assertions';
3+
4+
describe('examples/cognito-userpools', () => {
5+
let result: ReturnType<typeof synthesize>;
6+
7+
beforeAll(() => {
8+
result = synthesize('examples/cognito-userpools');
9+
});
10+
11+
afterAll(() => {
12+
result.cleanup();
13+
});
14+
15+
it('configures AMAZON_COGNITO_USER_POOLS authentication', () => {
16+
expectAuthenticationType(result.template, 'AMAZON_COGNITO_USER_POOLS');
17+
});
18+
19+
it('passes user pool config to the GraphQLApi', () => {
20+
const { resource } = getGraphQlApi(result.template);
21+
const config = resource.Properties?.UserPoolConfig as Record<
22+
string,
23+
unknown
24+
>;
25+
expect(config).toBeDefined();
26+
expect(config.DefaultAction).toBe('ALLOW');
27+
expect(config.AwsRegion).toBe('us-east-1');
28+
expect(config.UserPoolId).toBeDefined();
29+
});
30+
31+
it('does not create an API key for non-API_KEY auth', () => {
32+
const { template } = result;
33+
const apiKeys = Object.values(template.Resources).filter(
34+
(r) => r.Type === 'AWS::AppSync::ApiKey',
35+
);
36+
expect(apiKeys).toHaveLength(0);
37+
});
38+
});

e2e/custom-domain.e2e.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import { findOneResourceByType } from './helpers/assertions';
3+
4+
describe('examples/custom-domain', () => {
5+
let result: ReturnType<typeof synthesize>;
6+
7+
beforeAll(() => {
8+
result = synthesize('examples/custom-domain');
9+
});
10+
11+
afterAll(() => {
12+
result.cleanup();
13+
});
14+
15+
it('creates an AppSync DomainName resource', () => {
16+
const { resource } = findOneResourceByType(
17+
result.template,
18+
'AWS::AppSync::DomainName',
19+
);
20+
const props = resource.Properties as Record<string, unknown>;
21+
expect(props.DomainName).toBe('api.example.com');
22+
expect(props.CertificateArn).toBeDefined();
23+
});
24+
25+
it('creates a DomainNameApiAssociation', () => {
26+
findOneResourceByType(
27+
result.template,
28+
'AWS::AppSync::DomainNameApiAssociation',
29+
);
30+
});
31+
32+
it('creates a Route 53 record for the custom domain', () => {
33+
const { resource } = findOneResourceByType(
34+
result.template,
35+
'AWS::Route53::RecordSet',
36+
);
37+
const props = resource.Properties as Record<string, unknown>;
38+
expect(props.HostedZoneId).toBe('Z1234567890ABC');
39+
});
40+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import { expectDataSourceOfType } from './helpers/assertions';
3+
4+
describe('examples/datasource-eventbridge', () => {
5+
let result: ReturnType<typeof synthesize>;
6+
7+
beforeAll(() => {
8+
result = synthesize('examples/datasource-eventbridge');
9+
});
10+
11+
afterAll(() => {
12+
result.cleanup();
13+
});
14+
15+
it('creates an AMAZON_EVENTBRIDGE data source', () => {
16+
const ds = expectDataSourceOfType(result.template, 'AMAZON_EVENTBRIDGE');
17+
expect(ds.resource.Properties?.Name).toBe('event_bus');
18+
});
19+
20+
it('configures the event bus ARN', () => {
21+
const ds = expectDataSourceOfType(result.template, 'AMAZON_EVENTBRIDGE');
22+
const ebConfig = ds.resource.Properties?.EventBridgeConfig as Record<
23+
string,
24+
unknown
25+
>;
26+
expect(ebConfig).toBeDefined();
27+
expect(ebConfig.EventBusArn).toBeDefined();
28+
});
29+
});

0 commit comments

Comments
 (0)