Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ jobs:
matrix:
node: [20, 22, 24, 26]
steps:
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
- name: Lint
Expand All @@ -42,3 +42,18 @@ jobs:
echo "Error: lib directory is empty"
exit 1
fi

e2e:
name: CFN Synthesis Tests
runs-on: ubuntu-latest
needs: tests
steps:
- name: Checkout code
uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sid88in the plugin supports 20, 22, 24, and 26 versions, just curious why 22?

Copy link
Copy Markdown
Owner Author

@sid88in sid88in May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CFN output is identical across Node versions. Running 4 Node versions for the synthesis tests would be 4x the CI minutes for zero added signal. The unit-test matrix already verifies the plugin works across Node 20/22/24/26 — those tests exercise the actual plugin logic. The e2e job tests the output of that logic, which doesn't change based on Node.
That said, 22 is somewhat arbitrary — could have been any of the four. For now I will keep it 22

- name: Install dependencies
run: npm ci
- name: Run CFN synthesis tests
run: npm run test:e2e
92 changes: 92 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# CFN Synthesis Tests

These tests verify that `serverless-appsync-plugin` generates the
expected CloudFormation when applied to a variety of real-world
configurations. They complement the unit tests in `src/__tests__/`:

| Concern | Unit tests | CFN synthesis tests |
| --------------------------------------------- | ------------------- | ------------------- |
| Pure function logic | ✓ | |
| Schema validation | ✓ | |
| Type coercion | ✓ | |
| Plugin lifecycle on a real `serverless.yml` | | ✓ |
| Generated CloudFormation resources | partial (snapshots) | ✓ |
| Feature combinations | | ✓ |
| Example projects stay current with the plugin | | ✓ |

## How it works

Each test loads one of the example projects under [`../examples/`](../examples),
runs `serverless package` to produce a CloudFormation template, and
asserts on the generated resources.

Critically, **these tests do not deploy anything to AWS** — they only
exercise the synthesis path. That makes them fast (~3s per fixture),
fork-safe (no AWS credentials required), and suitable to run on every
PR.

## Running locally

```bash
# Run all CFN synthesis tests
npm run test:e2e

# Run everything (unit + synthesis)
npm run test:all

# Run a single fixture
npx jest --config jest.e2e.config.ts basic-api-key
```

## Adding a new test

1. Create a new example under `examples/<feature>/`:
- `serverless.yml` — minimal configuration demonstrating the feature
- `schema.graphql` — GraphQL schema (or multiple `.graphql` files)
- Any handlers or resolver code the example needs
2. Add an entry to [`../examples/README.md`](../examples/README.md)
3. Create `e2e/<feature>.e2e.test.ts` using the helpers from `helpers/`
4. Run `npm run test:e2e` to verify

The example should be **runnable**: a user should be able to `cd` into
it, run `serverless deploy` (with AWS credentials), and get a working
AppSync API. That keeps the examples honest as documentation.

## Helpers reference

See `helpers/synthesize.ts` and `helpers/assertions.ts` for the full
set of utilities. The common patterns:

```typescript
import { synthesize } from './helpers/synthesize';
import {
expectAuthenticationType,
expectDataSourceOfType,
expectResourceWithProperties,
findOneResourceByType,
getGraphQlApi,
} from './helpers/assertions';

describe('examples/my-feature', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/my-feature');
});

afterAll(() => {
result.cleanup();
});

it('does the thing', () => {
expectAuthenticationType(result.template, 'API_KEY');
});
});
```

## CI

A dedicated `e2e` job in `.github/workflows/ci.yml` runs the full
synthesis test suite on every PR and push to `master` or `alpha`. It
runs after the unit-test matrix completes successfully (no point
running E2E if unit tests already failed).
45 changes: 45 additions & 0 deletions e2e/api-keys-multiple.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { synthesize } from './helpers/synthesize';
import {
countResourcesByType,
findResourcesByType,
} from './helpers/assertions';

describe('examples/api-keys-multiple', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/api-keys-multiple');
});

afterAll(() => {
result.cleanup();
});

it('creates three distinct API key resources', () => {
expect(countResourcesByType(result.template, 'AWS::AppSync::ApiKey')).toBe(
3,
);
});

it('each API key has its own description matching the config', () => {
const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey');
const descriptions = keys
.map((k) => k.resource.Properties?.Description as string)
.sort();
expect(descriptions).toEqual([
'Internal testing',
'Mobile app key',
'Web app key',
]);
});

it('each API key has an expiry set', () => {
const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey');
keys.forEach((k) => {
// The plugin computes an absolute Unix timestamp for Expires
const expires = k.resource.Properties?.Expires;
expect(expires).toBeDefined();
expect(typeof expires).toBe('number');
});
});
});
62 changes: 62 additions & 0 deletions e2e/basic-api-key.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { synthesize } from './helpers/synthesize';
import {
countResourcesByType,
expectAuthenticationType,
expectDataSourceOfType,
expectResourceWithProperties,
findOneResourceByType,
getGraphQlApi,
} from './helpers/assertions';

describe('examples/basic-api-key', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/basic-api-key');
});

afterAll(() => {
result.cleanup();
});

it('produces a GraphQLApi with API_KEY authentication', () => {
const { resource } = getGraphQlApi(result.template);
expect(resource.Properties?.Name).toBe('basic-api-key');
expectAuthenticationType(result.template, 'API_KEY');
});

it('creates a default API key with the configured description', () => {
expectResourceWithProperties(result.template, 'AWS::AppSync::ApiKey', {
Description: 'Default API key',
});
});

it('creates a GraphQLSchema bound to the API', () => {
findOneResourceByType(result.template, 'AWS::AppSync::GraphQLSchema');
});

it('creates the DynamoDB data source with an IAM role', () => {
const ds = expectDataSourceOfType(result.template, 'AMAZON_DYNAMODB');
expect(ds.resource.Properties?.Name).toBe('users');
// Each AppSync data source needs an IAM role so AppSync can read/write
expect(
countResourcesByType(result.template, 'AWS::IAM::Role'),
).toBeGreaterThan(0);
});

it('creates the Query.getUser resolver wired to the data source', () => {
expectResourceWithProperties(result.template, 'AWS::AppSync::Resolver', {
TypeName: 'Query',
FieldName: 'getUser',
Kind: 'UNIT',
});
});

it('does not create any additional auth provider resources', () => {
// API_KEY only — no Cognito, no Lambda authorizer
const api = getGraphQlApi(result.template);
expect(
api.resource.Properties?.AdditionalAuthenticationProviders,
).toBeUndefined();
});
});
46 changes: 46 additions & 0 deletions e2e/caching.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { synthesize } from './helpers/synthesize';
import {
findOneResourceByType,
findResourcesByType,
} from './helpers/assertions';

describe('examples/caching', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/caching');
});

afterAll(() => {
result.cleanup();
});

it('creates an ApiCache resource', () => {
const { resource } = findOneResourceByType(
result.template,
'AWS::AppSync::ApiCache',
);
const props = resource.Properties as Record<string, unknown>;
expect(props.ApiCachingBehavior).toBe('PER_RESOLVER_CACHING');
expect(props.Type).toBe('SMALL');
expect(props.Ttl).toBe(600);
expect(props.AtRestEncryptionEnabled).toBe(true);
expect(props.TransitEncryptionEnabled).toBe(true);
});

it('configures caching keys on the resolver', () => {
const resolvers = findResourcesByType(
result.template,
'AWS::AppSync::Resolver',
);
expect(resolvers).toHaveLength(1);
const props = resolvers[0].resource.Properties as Record<string, unknown>;
const cacheConfig = props.CachingConfig as Record<string, unknown>;
expect(cacheConfig).toBeDefined();
expect(cacheConfig.Ttl).toBe(60);
expect(cacheConfig.CachingKeys).toEqual([
'$context.identity.username',
'$context.arguments.id',
]);
});
});
38 changes: 38 additions & 0 deletions e2e/cognito-userpools.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { synthesize } from './helpers/synthesize';
import { expectAuthenticationType, getGraphQlApi } from './helpers/assertions';

describe('examples/cognito-userpools', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/cognito-userpools');
});

afterAll(() => {
result.cleanup();
});

it('configures AMAZON_COGNITO_USER_POOLS authentication', () => {
expectAuthenticationType(result.template, 'AMAZON_COGNITO_USER_POOLS');
});

it('passes user pool config to the GraphQLApi', () => {
const { resource } = getGraphQlApi(result.template);
const config = resource.Properties?.UserPoolConfig as Record<
string,
unknown
>;
expect(config).toBeDefined();
expect(config.DefaultAction).toBe('ALLOW');
expect(config.AwsRegion).toBe('us-east-1');
expect(config.UserPoolId).toBeDefined();
});

it('does not create an API key for non-API_KEY auth', () => {
const { template } = result;
const apiKeys = Object.values(template.Resources).filter(
(r) => r.Type === 'AWS::AppSync::ApiKey',
);
expect(apiKeys).toHaveLength(0);
});
});
40 changes: 40 additions & 0 deletions e2e/custom-domain.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { synthesize } from './helpers/synthesize';
import { findOneResourceByType } from './helpers/assertions';

describe('examples/custom-domain', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/custom-domain');
});

afterAll(() => {
result.cleanup();
});

it('creates an AppSync DomainName resource', () => {
const { resource } = findOneResourceByType(
result.template,
'AWS::AppSync::DomainName',
);
const props = resource.Properties as Record<string, unknown>;
expect(props.DomainName).toBe('api.example.com');
expect(props.CertificateArn).toBeDefined();
});

it('creates a DomainNameApiAssociation', () => {
findOneResourceByType(
result.template,
'AWS::AppSync::DomainNameApiAssociation',
);
});

it('creates a Route 53 record for the custom domain', () => {
const { resource } = findOneResourceByType(
result.template,
'AWS::Route53::RecordSet',
);
const props = resource.Properties as Record<string, unknown>;
expect(props.HostedZoneId).toBe('Z1234567890ABC');
});
});
29 changes: 29 additions & 0 deletions e2e/datasource-eventbridge.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { synthesize } from './helpers/synthesize';
import { expectDataSourceOfType } from './helpers/assertions';

describe('examples/datasource-eventbridge', () => {
let result: ReturnType<typeof synthesize>;

beforeAll(() => {
result = synthesize('examples/datasource-eventbridge');
});

afterAll(() => {
result.cleanup();
});

it('creates an AMAZON_EVENTBRIDGE data source', () => {
const ds = expectDataSourceOfType(result.template, 'AMAZON_EVENTBRIDGE');
expect(ds.resource.Properties?.Name).toBe('event_bus');
});

it('configures the event bus ARN', () => {
const ds = expectDataSourceOfType(result.template, 'AMAZON_EVENTBRIDGE');
const ebConfig = ds.resource.Properties?.EventBridgeConfig as Record<
string,
unknown
>;
expect(ebConfig).toBeDefined();
expect(ebConfig.EventBusArn).toBeDefined();
});
});
Loading