diff --git a/CLAUDE.md b/CLAUDE.md index bc64583..2ef1c36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a .NET library that provides reusable AWS CDK constructs for serverless applications. The library is designed specifically for LayeredCraft projects and focuses on Lambda functions with integrated OpenTelemetry support, IAM management, and environment configuration. +This is a .NET library that provides reusable AWS CDK constructs for serverless applications and static websites. The library is designed specifically for LayeredCraft projects and includes: + +- **Lambda Functions**: Comprehensive Lambda construct with integrated OpenTelemetry support, IAM management, and environment configuration +- **Static Sites**: Complete static website hosting with S3, CloudFront, SSL certificates, Route53 DNS, and optional API proxying ## Commands @@ -47,9 +50,18 @@ dotnet add test/LayeredCraft.Cdk.Constructs.Tests/ package PackageName - Creates function versions and aliases for deployment management - Handles Lambda permissions for multiple targets (function, version, alias) +**StaticSiteConstruct** (`src/LayeredCraft.Cdk.Constructs/Constructs/StaticSiteConstruct.cs`) +- Comprehensive CDK construct for static website hosting +- Creates S3 bucket with public access for website hosting +- Sets up CloudFront distribution with SSL certificate and custom error pages +- Configures Route53 DNS records for primary domain and alternates +- Supports optional API proxying via CloudFront behaviors for `/api/*` paths +- Includes automatic asset deployment with cache invalidation + **Configuration Models** (`src/LayeredCraft.Cdk.Constructs/Models/`) - `LambdaFunctionConstructProps`: Main configuration interface and record for Lambda construct - `LambdaPermission`: Record for defining Lambda invocation permissions +- `StaticSiteConstructProps`: Configuration interface and record for static site construct ### Key Design Patterns @@ -78,12 +90,14 @@ dotnet add test/LayeredCraft.Cdk.Constructs.Tests/ package PackageName **Test Customizations**: - `LambdaFunctionConstructCustomization`: Configures realistic test data for Lambda constructs -- Supports parameterized test scenarios (OTEL layer on/off, permissions included/excluded) +- `StaticSiteConstructCustomization`: Configures realistic test data for static site constructs +- Supports parameterized test scenarios for Lambda (OTEL layer on/off, permissions included/excluded) and static sites (API domain on/off, alternate domains included/excluded) ### Test Data Strategy - Uses AutoFixture with custom configurations for realistic AWS resource names -- Test assets include `test-lambda.zip` for Lambda deployment packages +- Test assets include `test-lambda.zip` for Lambda deployment packages and `static-site/` folder for static site assets - Tests verify both resource creation and property configuration +- Supports parameterized scenarios for both Lambda (OTEL layer on/off, permissions included/excluded) and static sites (API domain on/off, alternate domains included/excluded) ### Testing Helpers (`src/LayeredCraft.Cdk.Constructs/Testing/`) The library includes comprehensive testing helpers for consumers: @@ -94,7 +108,8 @@ The library includes comprehensive testing helpers for consumers: - `CreateTestStackMinimal()`: Creates only the stack (app created internally) - `CreateTestStackMinimal()`: Creates custom stack types with app created internally - `GetTestAssetPath()`: Resolves test asset paths relative to executing assembly -- `CreatePropsBuilder()`: Creates fluent builder with test defaults +- `CreatePropsBuilder()`: Creates fluent builder with Lambda test defaults +- `CreateStaticSitePropsBuilder()`: Creates fluent builder with static site test defaults **LambdaFunctionConstructAssertions** (`LambdaFunctionConstructAssertions.cs`): - Extension methods for Template assertions @@ -104,6 +119,14 @@ The library includes comprehensive testing helpers for consumers: - Fluent builder for creating test props with common AWS service integrations - Methods like `WithDynamoDbAccess()`, `WithS3Access()`, `WithApiGatewayPermission()`, `WithSnapStart()` +**StaticSiteConstructAssertions** (`StaticSiteConstructAssertions.cs`): +- Extension methods for Template assertions specific to static sites +- `ShouldHaveCompleteStaticSite()`, `ShouldHaveStaticWebsiteBucket()`, `ShouldHaveCloudFrontDistribution()`, `ShouldHaveSSLCertificate()`, `ShouldHaveRoute53Records()`, `ShouldHaveApiProxyBehavior()`, etc. + +**StaticSiteConstructPropsBuilder** (`StaticSiteConstructPropsBuilder.cs`): +- Fluent builder for creating static site test props with scenario-based configurations +- Methods like `WithDomainName()`, `WithAlternateDomains()`, `WithApiDomain()`, `ForBlog()`, `ForDocumentation()`, `ForSinglePageApp()` + **Critical Testing Pattern**: - Always create CDK templates AFTER adding constructs to stacks - Use `Template.FromStack(stack)` after construct creation, not before diff --git a/README.md b/README.md index 35139bc..0473522 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![NuGet](https://img.shields.io/nuget/v/LayeredCraft.Cdk.Constructs.svg)](https://www.nuget.org/packages/LayeredCraft.Cdk.Constructs/) [![Downloads](https://img.shields.io/nuget/dt/LayeredCraft.Cdk.Constructs.svg)](https://www.nuget.org/packages/LayeredCraft.Cdk.Constructs/) -A reusable library of AWS CDK constructs for .NET projects, optimized for serverless applications using Lambda, API Gateway (or Lambda URLs), DynamoDB, S3, CloudFront, and OpenTelemetry. Built for speed, observability, and cost efficiency across the LayeredCraft project ecosystem. +A reusable library of AWS CDK constructs for .NET projects, optimized for serverless applications and static websites. Features comprehensive Lambda functions with OpenTelemetry support, and complete static site hosting with CloudFront CDN, SSL certificates, and DNS management. Built for speed, observability, and cost efficiency across the LayeredCraft project ecosystem. ## Installation @@ -114,6 +114,61 @@ The main construct that creates a complete Lambda function setup with: - **Versioning**: Automatic version creation with "live" alias - **Multi-target Permissions**: Applies permissions to function, version, and alias +### 🌐 StaticSiteConstruct + +A comprehensive construct for hosting static websites with global content delivery: + +- **S3 Static Website**: Bucket configured for website hosting with proper public access +- **CloudFront Distribution**: Global CDN with custom error pages and caching optimization +- **SSL Certificate**: Automatic SSL/TLS certificate with DNS validation +- **Route53 DNS**: A records with CloudFront alias targets for the domain and alternates +- **Optional API Proxying**: Forward `/api/*` requests to a backend API domain +- **Asset Deployment**: Automatic deployment of static assets with cache invalidation + +#### Quick Start - Static Site + +```csharp +using Amazon.CDK; +using LayeredCraft.Cdk.Constructs.Constructs; +using LayeredCraft.Cdk.Constructs.Models; + +public class MyStack : Stack +{ + public MyStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props) + { + var staticSite = new StaticSiteConstruct(this, "MySite", new StaticSiteConstructProps + { + DomainName = "example.com", + SiteSubDomain = "www", + AssetPath = "./build" + }); + } +} +``` + +#### Static Site with API Proxying + +```csharp +var staticSite = new StaticSiteConstruct(this, "MySite", new StaticSiteConstructProps +{ + DomainName = "example.com", + SiteSubDomain = "app", + AssetPath = "./build", + ApiDomain = "api.example.com", // Proxy /api/* to this domain + AlternateDomains = new[] { "example.com", "www.example.com" } +}); +``` + +#### Static Site Configuration Options + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `DomainName` | `string` | Root domain name (e.g., "example.com") | Required | +| `SiteSubDomain` | `string` | Subdomain for the site (e.g., "www", "app") | Required | +| `AssetPath` | `string` | Path to static assets directory | Required | +| `ApiDomain` | `string?` | API domain for /api/* proxying | `null` | +| `AlternateDomains` | `string[]` | Additional domains to serve content | `[]` | + ### 🔧 Configuration Options | Property | Type | Description | Default | @@ -153,6 +208,8 @@ The library includes comprehensive testing helpers to make it easy to unit test - **`CdkTestHelper`**: Reduces boilerplate for creating test stacks and props (including custom stack types) - **`LambdaFunctionConstructAssertions`**: Extension methods for asserting Lambda resources - **`LambdaFunctionConstructPropsBuilder`**: Fluent builder for creating test props +- **`StaticSiteConstructAssertions`**: Extension methods for asserting static site resources +- **`StaticSiteConstructPropsBuilder`**: Fluent builder for creating static site test props ### Quick Testing Example @@ -220,6 +277,65 @@ public void MyStack_ShouldCreateLegacyFunction() } ``` +### Example: Testing StaticSiteConstruct + +```csharp +using LayeredCraft.Cdk.Constructs.Constructs; +using LayeredCraft.Cdk.Constructs.Testing; + +[Fact] +public void MyStack_ShouldCreateStaticSiteWithApiProxy() +{ + // Create test infrastructure + var stack = CdkTestHelper.CreateTestStackMinimal(); + + // Build test props using fluent builder + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithDomainName("myapp.com") + .WithSiteSubDomain("app") + .WithApiDomain("api.myapp.com") + .WithAlternateDomains("myapp.com", "www.myapp.com") + .Build(); + + // Create the construct + _ = new StaticSiteConstruct(stack, "MyAppSite", props); + + // Create template AFTER adding constructs to the stack + var template = Template.FromStack(stack); + + // Use assertion helpers for clean, readable tests + template.ShouldHaveCompleteStaticSite("app.myapp.com", "app.myapp.com", + hasApiProxy: true, domainCount: 3); + template.ShouldHaveApiProxyBehavior("api.myapp.com"); + template.ShouldHaveRoute53Records("app.myapp.com"); + template.ShouldHaveRoute53Records("myapp.com"); + template.ShouldHaveRoute53Records("www.myapp.com"); +} +``` + +### Example: Testing Static Site Scenarios + +```csharp +[Fact] +public void MyStack_ShouldCreateBlogSite() +{ + var stack = CdkTestHelper.CreateTestStackMinimal(); + + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .ForBlog("myblog.com") + .Build(); + + _ = new StaticSiteConstruct(stack, "BlogSite", props); + + var template = Template.FromStack(stack); + + template.ShouldHaveCompleteStaticSite("www.myblog.com", "www.myblog.com", + hasApiProxy: false, domainCount: 2); + template.ShouldHaveRoute53Records("www.myblog.com"); + template.ShouldHaveRoute53Records("myblog.com"); +} +``` + ### Example: Testing Custom Stack Types For projects with custom stack implementations (inheriting from `Stack`), you can use the generic methods to create your custom stack types directly: @@ -321,31 +437,43 @@ public class MyStack : Stack ### Test Asset Management -Create a `TestAssets` folder in your test project and place your Lambda deployment packages there: +Create a `TestAssets` folder in your test project and place your deployment packages there: ``` YourTestProject/ ├── TestAssets/ -│ ├── test-lambda.zip # Default test asset -│ └── my-custom-lambda.zip # Custom test assets +│ ├── test-lambda.zip # Default Lambda test asset +│ ├── my-custom-lambda.zip # Custom Lambda test assets +│ └── static-site/ # Static site test assets +│ ├── index.html +│ ├── styles.css +│ ├── about.html +│ └── app.js └── YourTests.cs ``` The testing helpers automatically resolve test asset paths: ```csharp -// Uses TestAssets/test-lambda.zip by default -var props = CdkTestHelper.CreatePropsBuilder().Build(); +// Lambda testing - uses TestAssets/test-lambda.zip by default +var lambdaProps = CdkTestHelper.CreatePropsBuilder().Build(); -// Or specify your own test asset -var props = CdkTestHelper.CreatePropsBuilder("./TestAssets/my-custom-lambda.zip").Build(); +// Static site testing - uses TestAssets/static-site by default +var staticProps = CdkTestHelper.CreateStaticSitePropsBuilder().Build(); + +// Or specify your own test assets +var customLambdaProps = CdkTestHelper.CreatePropsBuilder("./TestAssets/my-custom-lambda.zip").Build(); +var customStaticProps = CdkTestHelper.CreateStaticSitePropsBuilder("./TestAssets/my-site").Build(); // Get reliable paths to test assets -var assetPath = CdkTestHelper.GetTestAssetPath("TestAssets/my-lambda.zip"); +var lambdaAssetPath = CdkTestHelper.GetTestAssetPath("TestAssets/my-lambda.zip"); +var staticAssetPath = CdkTestHelper.GetTestAssetPath("TestAssets/my-site"); ``` ### Available Assertion Methods +#### Lambda Function Assertions + | Method | Description | |--------|-------------| | `ShouldHaveLambdaFunction(functionName)` | Verify Lambda function exists | @@ -359,8 +487,24 @@ var assetPath = CdkTestHelper.GetTestAssetPath("TestAssets/my-lambda.zip"); | `ShouldHaveSnapStart()` | Verify SnapStart is enabled | | `ShouldNotHaveSnapStart()` | Verify SnapStart is disabled | +#### Static Site Assertions + +| Method | Description | +|--------|-------------| +| `ShouldHaveCompleteStaticSite(bucketName, domainName, hasApiProxy, domainCount)` | Verify complete static site setup | +| `ShouldHaveStaticWebsiteBucket(bucketName)` | Verify S3 bucket with website configuration | +| `ShouldHaveCloudFrontDistribution(domainName)` | Verify CloudFront distribution | +| `ShouldHaveSSLCertificate(domainName)` | Verify SSL certificate | +| `ShouldHaveRoute53Records(domainName)` | Verify Route53 A records | +| `ShouldHaveMultipleRoute53Records(count)` | Verify multiple domain records | +| `ShouldHaveApiProxyBehavior(apiDomain)` | Verify API proxy behavior | +| `ShouldNotHaveApiProxyBehavior()` | Verify no API proxy behavior | +| `ShouldHaveBucketDeployment()` | Verify asset deployment | + ### Props Builder Methods +#### Lambda Function Props Builder + | Method | Description | |--------|-------------| | `WithFunctionName(name)` | Set function name | @@ -377,24 +521,45 @@ var assetPath = CdkTestHelper.GetTestAssetPath("TestAssets/my-lambda.zip"); | `WithCustomPermission(permission)` | Add custom Lambda permission | | `WithSnapStart(bool)` | Enable/disable Lambda SnapStart | +#### Static Site Props Builder + +| Method | Description | +|--------|-------------| +| `WithDomainName(domain)` | Set root domain name | +| `WithSiteSubDomain(subdomain)` | Set site subdomain | +| `WithAssetPath(path)` | Set static assets directory path | +| `WithApiDomain(apiDomain)` | Set API domain for proxying | +| `WithoutApiDomain()` | Remove API domain | +| `WithAlternateDomain(domain)` | Add single alternate domain | +| `WithAlternateDomains(domains...)` | Add multiple alternate domains | +| `WithoutAlternateDomains()` | Clear all alternate domains | +| `ForBlog(domain)` | Configure for blog scenario (www + naked domain) | +| `ForDocumentation(domain, apiDomain)` | Configure for docs scenario with API | +| `ForSinglePageApp(domain, apiDomain)` | Configure for SPA scenario with API | + ## Project Structure ``` ├── src/ │ └── LayeredCraft.Cdk.Constructs/ # Main library package │ ├── Constructs/ -│ │ └── LambdaFunctionConstruct.cs # Lambda function construct with IAM and logging +│ │ ├── LambdaFunctionConstruct.cs # Lambda function construct with IAM and logging +│ │ └── StaticSiteConstruct.cs # Static site construct with CDN and DNS │ ├── Models/ │ │ ├── LambdaFunctionConstructProps.cs # Configuration props for Lambda construct -│ │ └── LambdaPermission.cs # Lambda permission model +│ │ ├── LambdaPermission.cs # Lambda permission model +│ │ └── StaticSiteConstructProps.cs # Configuration props for static site construct │ └── Testing/ # Testing helpers for consumers │ ├── CdkTestHelper.cs # Test stack and props creation utilities -│ ├── LambdaFunctionConstructAssertions.cs # Extension methods for assertions -│ └── LambdaFunctionConstructPropsBuilder.cs # Fluent builder for test props +│ ├── LambdaFunctionConstructAssertions.cs # Extension methods for Lambda assertions +│ ├── LambdaFunctionConstructPropsBuilder.cs # Fluent builder for Lambda test props +│ ├── StaticSiteConstructAssertions.cs # Extension methods for static site assertions +│ └── StaticSiteConstructPropsBuilder.cs # Fluent builder for static site test props ├── test/ │ └── LayeredCraft.Cdk.Constructs.Tests/ # Test suite │ ├── Constructs/ -│ │ └── LambdaFunctionConstructTests.cs # Unit tests for Lambda construct +│ │ ├── LambdaFunctionConstructTests.cs # Unit tests for Lambda construct +│ │ └── StaticSiteConstructTests.cs # Unit tests for static site construct │ ├── Testing/ │ │ └── TestingHelpersTests.cs # Tests for testing helper functionality │ ├── TestKit/ # Test utilities and fixtures @@ -402,7 +567,12 @@ var assetPath = CdkTestHelper.GetTestAssetPath("TestAssets/my-lambda.zip"); │ │ ├── Customizations/ # Test data customizations │ │ └── Extensions/ # Test extension methods │ └── TestAssets/ -│ └── test-lambda.zip # Test Lambda deployment package +│ ├── test-lambda.zip # Test Lambda deployment package +│ └── static-site/ # Test static site assets +│ ├── index.html +│ ├── styles.css +│ ├── about.html +│ └── app.js └── LayeredCraft.Cdk.Constructs.sln # Solution file ``` diff --git a/src/LayeredCraft.Cdk.Constructs/Constructs/StaticSiteConstruct.cs b/src/LayeredCraft.Cdk.Constructs/Constructs/StaticSiteConstruct.cs new file mode 100644 index 0000000..3f910ba --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Constructs/StaticSiteConstruct.cs @@ -0,0 +1,141 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.CertificateManager; +using Amazon.CDK.AWS.CloudFront; +using Amazon.CDK.AWS.CloudFront.Origins; +using Amazon.CDK.AWS.Route53; +using Amazon.CDK.AWS.Route53.Targets; +using Amazon.CDK.AWS.S3; +using Amazon.CDK.AWS.S3.Deployment; +using Constructs; +using LayeredCraft.Cdk.Constructs.Models; + +namespace LayeredCraft.Cdk.Constructs.Constructs; + +/// +/// AWS CDK construct for creating a complete static website infrastructure. +/// This construct creates an S3 bucket for hosting static content, CloudFront distribution for global content delivery, +/// SSL certificate for HTTPS, and Route53 DNS records for domain configuration. +/// Optionally supports API proxying through CloudFront behaviors. +/// +public class StaticSiteConstruct : Construct +{ + /// + /// Initializes a new instance of the StaticSiteConstruct class. + /// + /// The parent construct + /// The construct ID + /// Configuration properties for the static site + public StaticSiteConstruct(Construct scope, string id, IStaticSiteConstructProps props) : base(scope, id) + { + // Lookup existing Route53 hosted zone for the domain + var zone = HostedZone.FromLookup(this, id, new HostedZoneProviderProps + { + DomainName = props.DomainName + }); + + var siteDomain = $"{props.SiteSubDomain}.{props.DomainName}"; + + // Create S3 bucket for static website hosting + var siteBucket = new Bucket(this, $"{id}-bucket", new BucketProps + { + BucketName = siteDomain, + WebsiteIndexDocument = "index.html", + WebsiteErrorDocument = "index.html", + PublicReadAccess = true, + BlockPublicAccess = new BlockPublicAccess(new BlockPublicAccessOptions + { + BlockPublicPolicy = false, + BlockPublicAcls = false, + IgnorePublicAcls = false, + RestrictPublicBuckets = false + }), + RemovalPolicy = RemovalPolicy.DESTROY, + Versioned = false, + AutoDeleteObjects = true, + LifecycleRules = [new LifecycleRule + { + Enabled = true, + NoncurrentVersionExpiration = Duration.Days(1) + }] + }); + + // Create SSL certificate for HTTPS with DNS validation + var certificate = new Certificate(this, $"{id}-certificate", new CertificateProps + { + DomainName = siteDomain, + Validation = CertificateValidation.FromDns(zone), + SubjectAlternativeNames = props.AlternateDomains, + }); + + // Create CloudFront distribution for global content delivery + var distribution = new Distribution(this, $"{id}-cdn", new DistributionProps + { + DomainNames = new[] { siteDomain }.Concat(props.AlternateDomains).ToArray(), + DefaultBehavior = new BehaviorOptions + { + Origin = new S3StaticWebsiteOrigin(siteBucket, new S3StaticWebsiteOriginProps + { + ProtocolPolicy = OriginProtocolPolicy.HTTP_ONLY + }), + AllowedMethods = AllowedMethods.ALLOW_GET_HEAD, + Compress = true + }, + Certificate = certificate, + ErrorResponses = + [ + new ErrorResponse + { + HttpStatus = 403, + ResponseHttpStatus = 200, + ResponsePagePath = "/index.html" + } + ] + }); + + // Add API proxying behavior if API domain is specified + if (!string.IsNullOrWhiteSpace(props.ApiDomain)) + { + distribution.AddBehavior("/api/*", new HttpOrigin(props.ApiDomain, new HttpOriginProps + { + ProtocolPolicy = OriginProtocolPolicy.HTTPS_ONLY + }), new BehaviorOptions + { + AllowedMethods = AllowedMethods.ALLOW_ALL, + CachePolicy = CachePolicy.CACHING_DISABLED, + OriginRequestPolicy = OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER, + ViewerProtocolPolicy = ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + Compress = true + }); + } + + // Create primary DNS A record for the site domain + _ = new ARecord(this, $"{id}-alias-record", new ARecordProps + { + Zone = zone, + RecordName = siteDomain, + Target = RecordTarget.FromAlias(new CloudFrontTarget(distribution)), + }); + + // Create DNS A records for alternate domains + var counter = 0; + foreach (var domain in props.AlternateDomains) + { + _ = new ARecord(this, $"{id}-alias-record-{counter++}", new ARecordProps + { + Zone = zone, + RecordName = domain, + Target = RecordTarget.FromAlias(new CloudFrontTarget(distribution)) + }); + } + + // Deploy static assets to S3 bucket and invalidate CloudFront cache + _ = new BucketDeployment(this, $"{id}-deployment", new BucketDeploymentProps + { + Sources = [Source.Asset(props.AssetPath)], + DestinationBucket = siteBucket, + Distribution = distribution, + DistributionPaths = ["/*"], + MemoryLimit = 1024 + }); + } +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Models/StaticSiteConstructProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/StaticSiteConstructProps.cs new file mode 100644 index 0000000..22e72dd --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/StaticSiteConstructProps.cs @@ -0,0 +1,52 @@ +namespace LayeredCraft.Cdk.Constructs.Models; + +/// +/// Interface defining configuration properties for StaticSiteConstruct. +/// Contains all required and optional settings for deploying a static website with CloudFront distribution. +/// +public interface IStaticSiteConstructProps +{ + /// + /// The root domain name for the static site (e.g., "example.com"). + /// Must correspond to an existing Route53 hosted zone. + /// + string DomainName { get; set; } + /// + /// The subdomain for the static site (e.g., "www" for "www.example.com"). + /// Combined with DomainName to form the full site domain. + /// + string SiteSubDomain { get; set; } + /// + /// Path to the static website assets that will be deployed to S3. + /// Can be a directory containing the built website files. + /// + string AssetPath { get; set; } + /// + /// Optional API domain for proxying API requests through CloudFront. + /// When specified, requests to /api/* will be forwarded to this domain. + /// + string? ApiDomain { get; set; } + /// + /// Additional domain names that should be included in the SSL certificate and CloudFront distribution. + /// Useful for supporting multiple domains or subdomains pointing to the same site. + /// + string[] AlternateDomains { get; set; } +} + +/// +/// Implementation record for StaticSiteConstruct configuration properties. +/// Provides a concrete implementation of IStaticSiteConstructProps with sensible defaults. +/// +public record StaticSiteConstructProps : IStaticSiteConstructProps +{ + /// + public required string DomainName { get; set; } + /// + public required string SiteSubDomain { get; set; } + /// + public required string AssetPath { get; set; } + /// + public string? ApiDomain { get; set; } + /// + public string[] AlternateDomains { get; set; } = []; +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs b/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs index 42a46c0..ba2626d 100644 --- a/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs +++ b/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs @@ -1,6 +1,5 @@ using System.Reflection; using Amazon.CDK; -using Amazon.CDK.Assertions; namespace LayeredCraft.Cdk.Constructs.Testing; @@ -200,4 +199,20 @@ public static LambdaFunctionConstructPropsBuilder CreatePropsBuilder(string? ass .WithEnvironmentVariable("ENVIRONMENT", "test") .WithOtelEnabled(true); } + + /// + /// Creates a StaticSiteConstructPropsBuilder with sensible test defaults. + /// Uses the executing assembly location to find test assets reliably. + /// + /// Optional custom asset path. If not provided, uses TestAssets/static-site + /// A configured builder for creating test props + public static StaticSiteConstructPropsBuilder CreateStaticSitePropsBuilder(string? assetPath = null) + { + var defaultAssetPath = assetPath ?? GetTestAssetPath("TestAssets/static-site"); + + return new StaticSiteConstructPropsBuilder() + .WithDomainName("example.com") + .WithSiteSubDomain("www") + .WithAssetPath(defaultAssetPath); + } } \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Testing/StaticSiteConstructAssertions.cs b/src/LayeredCraft.Cdk.Constructs/Testing/StaticSiteConstructAssertions.cs new file mode 100644 index 0000000..537c71e --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Testing/StaticSiteConstructAssertions.cs @@ -0,0 +1,222 @@ +using Amazon.CDK.Assertions; + +namespace LayeredCraft.Cdk.Constructs.Testing; + +/// +/// Extension methods for asserting StaticSiteConstruct resources in CDK templates. +/// These helpers simplify common testing scenarios for consumers of the library. +/// +public static class StaticSiteConstructAssertions +{ + /// + /// Asserts that the template contains an S3 bucket with static website hosting configuration. + /// + /// The CDK template to assert against + /// The expected bucket name + public static void ShouldHaveStaticWebsiteBucket(this Template template, string bucketName) + { + template.HasResourceProperties("AWS::S3::Bucket", Match.ObjectLike(new Dictionary + { + { "BucketName", bucketName }, + { "WebsiteConfiguration", Match.ObjectLike(new Dictionary + { + { "IndexDocument", "index.html" }, + { "ErrorDocument", "index.html" } + }) }, + { "PublicAccessBlockConfiguration", Match.ObjectLike(new Dictionary + { + { "BlockPublicPolicy", false }, + { "BlockPublicAcls", false }, + { "IgnorePublicAcls", false }, + { "RestrictPublicBuckets", false } + }) } + })); + } + + /// + /// Asserts that the template contains a CloudFront distribution with the specified domain. + /// + /// The CDK template to assert against + /// The expected primary domain name + public static void ShouldHaveCloudFrontDistribution(this Template template, string domainName) + { + template.HasResourceProperties("AWS::CloudFront::Distribution", Match.ObjectLike(new Dictionary + { + { "DistributionConfig", Match.ObjectLike(new Dictionary + { + { "Aliases", Match.ArrayWith([domainName]) }, + { "Enabled", true }, + { "DefaultRootObject", Match.Absent() }, + { "CustomErrorResponses", Match.ArrayWith([ + Match.ObjectLike(new Dictionary + { + { "ErrorCode", 403 }, + { "ResponseCode", 200 }, + { "ResponsePagePath", "/index.html" } + }) + ]) } + }) } + })); + } + + /// + /// Asserts that the template contains a CloudFront distribution with API behavior for /api/* paths. + /// + /// The CDK template to assert against + /// The expected API domain + public static void ShouldHaveApiProxyBehavior(this Template template, string apiDomain) + { + template.HasResourceProperties("AWS::CloudFront::Distribution", Match.ObjectLike(new Dictionary + { + { "DistributionConfig", Match.ObjectLike(new Dictionary + { + { "CacheBehaviors", Match.ArrayWith([ + Match.ObjectLike(new Dictionary + { + { "PathPattern", "/api/*" }, + { "TargetOriginId", Match.AnyValue() }, + { "ViewerProtocolPolicy", "redirect-to-https" }, + { "AllowedMethods", Match.AnyValue() }, + { "CachePolicyId", Match.AnyValue() }, + { "OriginRequestPolicyId", Match.AnyValue() }, + { "Compress", true } + }) + ]) } + }) } + })); + + // Also verify the HTTP origin exists for the API domain + template.HasResourceProperties("AWS::CloudFront::Distribution", Match.ObjectLike(new Dictionary + { + { "DistributionConfig", Match.ObjectLike(new Dictionary + { + { "Origins", Match.ArrayWith([ + Match.ObjectLike(new Dictionary + { + { "DomainName", apiDomain }, + { "CustomOriginConfig", Match.ObjectLike(new Dictionary + { + { "OriginProtocolPolicy", "https-only" } + }) } + }) + ]) } + }) } + })); + } + + /// + /// Asserts that the template does not contain API proxy behavior (no /api/* cache behaviors). + /// + /// The CDK template to assert against + public static void ShouldNotHaveApiProxyBehavior(this Template template) + { + // Check that there are no cache behaviors for /api/* + template.HasResourceProperties("AWS::CloudFront::Distribution", Match.Not(Match.ObjectLike(new Dictionary + { + { "DistributionConfig", Match.ObjectLike(new Dictionary + { + { "CacheBehaviors", Match.ArrayWith([ + Match.ObjectLike(new Dictionary + { + { "PathPattern", "/api/*" } + }) + ]) } + }) } + }))); + } + + /// + /// Asserts that the template contains an SSL certificate for the specified domain. + /// + /// The CDK template to assert against + /// The expected primary domain name + public static void ShouldHaveSSLCertificate(this Template template, string domainName) + { + template.HasResourceProperties("AWS::CertificateManager::Certificate", Match.ObjectLike(new Dictionary + { + { "DomainName", domainName }, + { "ValidationMethod", "DNS" } + })); + } + + /// + /// Asserts that the template contains Route53 A records for the specified domain. + /// + /// The CDK template to assert against + /// The expected domain name + public static void ShouldHaveRoute53Records(this Template template, string domainName) + { + template.HasResourceProperties("AWS::Route53::RecordSet", Match.ObjectLike(new Dictionary + { + { "Name", $"{domainName}." }, + { "Type", "A" }, + { "AliasTarget", Match.ObjectLike(new Dictionary + { + { "DNSName", Match.AnyValue() }, + { "HostedZoneId", Match.AnyValue() } + }) } + })); + } + + /// + /// Asserts that the template contains multiple Route53 A records for alternate domains. + /// + /// The CDK template to assert against + /// Expected number of domain records (primary + alternates) + public static void ShouldHaveMultipleRoute53Records(this Template template, int domainCount) + { + template.ResourceCountIs("AWS::Route53::RecordSet", domainCount); + } + + /// + /// Asserts that the template contains a bucket deployment for static assets. + /// CDK BucketDeployment creates a Custom::CDKBucketDeployment resource, not AWS::S3::BucketDeployment. + /// + /// The CDK template to assert against + public static void ShouldHaveBucketDeployment(this Template template) + { + template.HasResourceProperties("Custom::CDKBucketDeployment", Match.ObjectLike(new Dictionary + { + { "DestinationBucketName", Match.AnyValue() }, + { "SourceBucketNames", Match.AnyValue() }, + { "DistributionId", Match.AnyValue() }, + { "DistributionPaths", Match.ArrayWith(["/*"]) } + })); + } + + /// + /// Asserts that the template contains all expected resources for a complete static site. + /// + /// The CDK template to assert against + /// The expected S3 bucket name + /// The expected primary domain name + /// Whether API proxy behavior should be present + /// Expected number of domain records + public static void ShouldHaveCompleteStaticSite(this Template template, string bucketName, string domainName, + bool hasApiProxy = false, int domainCount = 1) + { + template.ShouldHaveStaticWebsiteBucket(bucketName); + template.ShouldHaveCloudFrontDistribution(domainName); + template.ShouldHaveSSLCertificate(domainName); + template.ShouldHaveRoute53Records(domainName); + template.ShouldHaveMultipleRoute53Records(domainCount); + template.ShouldHaveBucketDeployment(); + + if (hasApiProxy) + { + // Note: API domain assertion would need the actual API domain value + // This is a simplified check for cache behavior existence + template.HasResourceProperties("AWS::CloudFront::Distribution", Match.ObjectLike(new Dictionary + { + { "DistributionConfig", Match.ObjectLike(new Dictionary + { + { "CacheBehaviors", Match.AnyValue() } + }) } + })); + } + else + { + template.ShouldNotHaveApiProxyBehavior(); + } + } +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Testing/StaticSiteConstructPropsBuilder.cs b/src/LayeredCraft.Cdk.Constructs/Testing/StaticSiteConstructPropsBuilder.cs new file mode 100644 index 0000000..b0514ad --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Testing/StaticSiteConstructPropsBuilder.cs @@ -0,0 +1,160 @@ +using LayeredCraft.Cdk.Constructs.Models; + +namespace LayeredCraft.Cdk.Constructs.Testing; + +/// +/// Fluent builder for creating StaticSiteConstructProps instances in tests. +/// Provides sensible defaults and allows customization of specific properties. +/// +public class StaticSiteConstructPropsBuilder +{ + private string _domainName = "example.com"; + private string _siteSubDomain = "www"; + private string _assetPath = CdkTestHelper.GetTestAssetPath("TestAssets/static-site"); + private string? _apiDomain = null; + private readonly List _alternateDomains = new(); + + /// + /// Sets the root domain name. + /// + /// The root domain name + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithDomainName(string domainName) + { + _domainName = domainName; + return this; + } + + /// + /// Sets the site subdomain. + /// + /// The site subdomain + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithSiteSubDomain(string siteSubDomain) + { + _siteSubDomain = siteSubDomain; + return this; + } + + /// + /// Sets the asset path for the static website files. + /// + /// The asset path + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithAssetPath(string assetPath) + { + _assetPath = assetPath; + return this; + } + + /// + /// Sets the API domain for proxying API requests. + /// + /// The API domain + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithApiDomain(string apiDomain) + { + _apiDomain = apiDomain; + return this; + } + + /// + /// Removes the API domain (sets to null). + /// + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithoutApiDomain() + { + _apiDomain = null; + return this; + } + + /// + /// Adds an alternate domain to the list. + /// + /// The alternate domain to add + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithAlternateDomain(string alternateDomain) + { + _alternateDomains.Add(alternateDomain); + return this; + } + + /// + /// Adds multiple alternate domains to the list. + /// + /// The alternate domains to add + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithAlternateDomains(params string[] alternateDomains) + { + _alternateDomains.AddRange(alternateDomains); + return this; + } + + /// + /// Clears all alternate domains. + /// + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder WithoutAlternateDomains() + { + _alternateDomains.Clear(); + return this; + } + + /// + /// Configures for a typical blog setup with www and naked domain. + /// + /// The base domain (e.g., "myblog.com") + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder ForBlog(string domain) + { + _domainName = domain; + _siteSubDomain = "www"; + _alternateDomains.Clear(); + _alternateDomains.Add(domain); // Naked domain as alternate + return this; + } + + /// + /// Configures for a documentation site setup. + /// + /// The base domain + /// Optional API domain for documentation API + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder ForDocumentation(string domain, string? apiDomain = null) + { + _domainName = domain; + _siteSubDomain = "docs"; + _apiDomain = apiDomain; + return this; + } + + /// + /// Configures for a SPA (Single Page Application) with API integration. + /// + /// The base domain + /// The API domain for backend services + /// The builder instance for method chaining + public StaticSiteConstructPropsBuilder ForSinglePageApp(string domain, string apiDomain) + { + _domainName = domain; + _siteSubDomain = "app"; + _apiDomain = apiDomain; + return this; + } + + /// + /// Builds the StaticSiteConstructProps instance. + /// + /// The configured StaticSiteConstructProps + public StaticSiteConstructProps Build() + { + return new StaticSiteConstructProps + { + DomainName = _domainName, + SiteSubDomain = _siteSubDomain, + AssetPath = _assetPath, + ApiDomain = _apiDomain, + AlternateDomains = _alternateDomains.ToArray() + }; + } +} \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/Constructs/StaticSiteConstructTests.cs b/test/LayeredCraft.Cdk.Constructs.Tests/Constructs/StaticSiteConstructTests.cs new file mode 100644 index 0000000..79eccd0 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/Constructs/StaticSiteConstructTests.cs @@ -0,0 +1,371 @@ +using Amazon.CDK.Assertions; +using LayeredCraft.Cdk.Constructs.Constructs; +using LayeredCraft.Cdk.Constructs.Models; +using LayeredCraft.Cdk.Constructs.Testing; +using LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes; + +namespace LayeredCraft.Cdk.Constructs.Tests.Constructs; + +[Collection("CDK Tests")] +public class StaticSiteConstructTests +{ + [Fact] + public void Construct_ShouldCreateBasicStaticSite() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithDomainName("example.com") + .WithSiteSubDomain("www") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .WithoutApiDomain() + .WithoutAlternateDomains() + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestStaticSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("www.example.com", "www.example.com", hasApiProxy: false, domainCount: 1); + } + + [Theory] + [StaticSiteConstructAutoData(includeApiDomain: false, includeAlternateDomains: false)] + public void Construct_WithAutoFixtureData_ShouldCreateBasicStaticSite(StaticSiteConstructProps props) + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + + // Act + _ = new StaticSiteConstruct(stack, "TestStaticSite", props); + var template = Template.FromStack(stack); + + // Assert + var expectedDomain = $"{props.SiteSubDomain}.{props.DomainName}"; + template.ShouldHaveStaticWebsiteBucket(expectedDomain); + template.ShouldHaveCloudFrontDistribution(expectedDomain); + template.ShouldHaveSSLCertificate(expectedDomain); + template.ShouldHaveRoute53Records(expectedDomain); + template.ShouldNotHaveApiProxyBehavior(); + } + + [Theory] + [StaticSiteConstructAutoData(includeApiDomain: true, includeAlternateDomains: false)] + public void Construct_WithApiDomain_ShouldCreateApiProxyBehavior(StaticSiteConstructProps props) + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + + // Act + _ = new StaticSiteConstruct(stack, "TestStaticSite", props); + var template = Template.FromStack(stack); + + // Assert + var expectedDomain = $"{props.SiteSubDomain}.{props.DomainName}"; + template.ShouldHaveCompleteStaticSite(expectedDomain, expectedDomain, hasApiProxy: true, domainCount: 1); + template.ShouldHaveApiProxyBehavior(props.ApiDomain!); + } + + [Theory] + [StaticSiteConstructAutoData(includeApiDomain: false, includeAlternateDomains: true)] + public void Construct_WithAlternateDomains_ShouldCreateMultipleRoute53Records(StaticSiteConstructProps props) + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + + // Act + _ = new StaticSiteConstruct(stack, "TestStaticSite", props); + var template = Template.FromStack(stack); + + // Assert + var expectedDomain = $"{props.SiteSubDomain}.{props.DomainName}"; + var expectedRecordCount = 1 + props.AlternateDomains.Length; // Primary + alternates + template.ShouldHaveCompleteStaticSite(expectedDomain, expectedDomain, hasApiProxy: false, domainCount: expectedRecordCount); + } + + [Fact] + public void Construct_ForBlogScenario_ShouldCreateProperConfiguration() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .ForBlog("myblog.com") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "BlogSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("www.myblog.com", "www.myblog.com", hasApiProxy: false, domainCount: 2); + template.ShouldHaveRoute53Records("www.myblog.com"); + template.ShouldHaveRoute53Records("myblog.com"); + } + + [Fact] + public void Construct_ForDocumentationScenario_ShouldCreateDocsSubdomain() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .ForDocumentation("mycompany.com", "api.mycompany.com") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "DocsSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("docs.mycompany.com", "docs.mycompany.com", hasApiProxy: true, domainCount: 1); + template.ShouldHaveApiProxyBehavior("api.mycompany.com"); + } + + [Fact] + public void Construct_ForSinglePageAppScenario_ShouldCreateAppSubdomainWithApi() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .ForSinglePageApp("myapp.com", "api.myapp.com") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "AppSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("app.myapp.com", "app.myapp.com", hasApiProxy: true, domainCount: 1); + template.ShouldHaveApiProxyBehavior("api.myapp.com"); + } + + [Fact] + public void Construct_ShouldCreateS3BucketWithCorrectConfiguration() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithDomainName("test.com") + .WithSiteSubDomain("www") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.HasResourceProperties("AWS::S3::Bucket", Match.ObjectLike(new Dictionary + { + { "BucketName", "www.test.com" }, + { "WebsiteConfiguration", Match.ObjectLike(new Dictionary + { + { "IndexDocument", "index.html" }, + { "ErrorDocument", "index.html" } + }) }, + { "PublicAccessBlockConfiguration", Match.ObjectLike(new Dictionary + { + { "BlockPublicPolicy", false }, + { "BlockPublicAcls", false }, + { "IgnorePublicAcls", false }, + { "RestrictPublicBuckets", false } + }) } + })); + } + + [Fact] + public void Construct_ShouldCreateCloudFrontDistributionWithCorrectCaching() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithDomainName("test.com") + .WithSiteSubDomain("www") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.HasResourceProperties("AWS::CloudFront::Distribution", Match.ObjectLike(new Dictionary + { + { "DistributionConfig", Match.ObjectLike(new Dictionary + { + { "Enabled", true }, + { "Aliases", Match.ArrayWith(["www.test.com"]) }, + { "DefaultCacheBehavior", Match.ObjectLike(new Dictionary + { + { "AllowedMethods", Match.ArrayWith(["GET", "HEAD"]) }, + { "Compress", true } + }) }, + { "CustomErrorResponses", Match.ArrayWith([ + Match.ObjectLike(new Dictionary + { + { "ErrorCode", 403 }, + { "ResponseCode", 200 }, + { "ResponsePagePath", "/index.html" } + }) + ]) } + }) } + })); + } + + [Fact] + public void Construct_ShouldCreateBucketDeploymentWithCorrectConfiguration() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveBucketDeployment(); + template.HasResourceProperties("Custom::CDKBucketDeployment", Match.ObjectLike(new Dictionary + { + { "DistributionPaths", Match.ArrayWith(["/*"]) } + })); + } + + [Fact] + public void Construct_WithNullApiDomain_ShouldNotCreateApiProxyBehavior() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithoutApiDomain() + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldNotHaveApiProxyBehavior(); + } + + [Fact] + public void Construct_ShouldCreateMinimalResources() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithoutApiDomain() + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert - Test only basic resource creation without BucketDeployment + template.ResourceCountIs("AWS::S3::Bucket", 1); + template.ResourceCountIs("AWS::CloudFront::Distribution", 1); + template.ResourceCountIs("AWS::CertificateManager::Certificate", 1); + template.ResourceCountIs("AWS::Route53::RecordSet", 1); + } + + [Fact] + public void Construct_WithEmptyApiDomain_ShouldNotCreateApiProxyBehavior() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithApiDomain("") + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldNotHaveApiProxyBehavior(); + } + + [Fact] + public void Construct_WithWhitespaceApiDomain_ShouldNotCreateApiProxyBehavior() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithApiDomain(" ") + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldNotHaveApiProxyBehavior(); + } + + [Fact] + public void Construct_WithSingleAlternateDomain_ShouldCreateTwoRoute53Records() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithAlternateDomain("alt.example.com") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("www.example.com", "www.example.com", hasApiProxy: false, domainCount: 2); + template.ShouldHaveRoute53Records("www.example.com"); + template.ShouldHaveRoute53Records("alt.example.com"); + } + + [Fact] + public void Construct_WithMultipleAlternateDomains_ShouldCreateCorrectNumberOfRoute53Records() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithAlternateDomains("alt1.example.com", "alt2.example.com", "alt3.example.com") + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("www.example.com", "www.example.com", hasApiProxy: false, domainCount: 4); + template.ShouldHaveRoute53Records("www.example.com"); + template.ShouldHaveRoute53Records("alt1.example.com"); + template.ShouldHaveRoute53Records("alt2.example.com"); + template.ShouldHaveRoute53Records("alt3.example.com"); + } + + [Fact] + public void Construct_WithoutAlternateDomains_ShouldClearExistingAlternates() + { + // Arrange + var (_, stack) = CdkTestHelper.CreateTestStack(); + var props = CdkTestHelper.CreateStaticSitePropsBuilder() + .WithAlternateDomains("temp1.example.com", "temp2.example.com") + .WithoutAlternateDomains() + .WithAssetPath(CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .Build(); + + // Act + _ = new StaticSiteConstruct(stack, "TestSite", props); + var template = Template.FromStack(stack); + + // Assert + template.ShouldHaveCompleteStaticSite("www.example.com", "www.example.com", hasApiProxy: false, domainCount: 1); + template.ShouldHaveRoute53Records("www.example.com"); + } +} \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj b/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj index 674d918..b1ece75 100644 --- a/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj +++ b/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj @@ -47,6 +47,9 @@ PreserveNewest + + PreserveNewest + diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/about.html b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/about.html new file mode 100644 index 0000000..51fc1d7 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/about.html @@ -0,0 +1,34 @@ + + + + + + About - Test Static Site + + + +
+

About

+ +
+ +
+
+

About This Test Site

+

This is an about page for the test static website.

+

It demonstrates multi-page functionality and routing.

+
+
+ +
+

© 2024 Test Static Site. All rights reserved.

+
+ + + + \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/app.js b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/app.js new file mode 100644 index 0000000..3d2d895 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/app.js @@ -0,0 +1,27 @@ +// Test Static Site JavaScript + +document.addEventListener('DOMContentLoaded', function() { + console.log('Test static site loaded successfully'); + + // Add some basic interactivity for testing + const navLinks = document.querySelectorAll('nav a'); + navLinks.forEach(link => { + link.addEventListener('click', function(e) { + console.log('Navigation link clicked:', this.href); + }); + }); + + // Test API functionality if available + if (window.location.pathname.includes('/api/')) { + console.log('API route detected'); + } + + // Simple feature detection + const features = { + localStorage: typeof(Storage) !== "undefined", + fetch: typeof(fetch) !== "undefined", + webGL: !!window.WebGLRenderingContext + }; + + console.log('Browser features:', features); +}); \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/index.html b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/index.html new file mode 100644 index 0000000..2260a64 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/index.html @@ -0,0 +1,34 @@ + + + + + + Test Static Site + + + +
+

Test Static Site

+ +
+ +
+
+

Welcome to the Test Site

+

This is a test static website used for CDK construct testing.

+

It includes multiple pages and assets to verify proper deployment.

+
+
+ +
+

© 2024 Test Static Site. All rights reserved.

+
+ + + + \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/styles.css b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/styles.css new file mode 100644 index 0000000..9807567 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/static-site/styles.css @@ -0,0 +1,62 @@ +/* Test Static Site Styles */ + +body { + font-family: Arial, sans-serif; + line-height: 1.6; + margin: 0; + padding: 0; + color: #333; + background-color: #f4f4f4; +} + +header { + background-color: #333; + color: white; + padding: 1rem 0; + text-align: center; +} + +header h1 { + margin: 0; +} + +nav ul { + list-style: none; + padding: 0; + margin: 1rem 0 0 0; +} + +nav ul li { + display: inline; + margin: 0 10px; +} + +nav ul li a { + color: white; + text-decoration: none; +} + +nav ul li a:hover { + text-decoration: underline; +} + +main { + max-width: 800px; + margin: 2rem auto; + padding: 0 1rem; + background-color: white; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +} + +section { + padding: 2rem; +} + +footer { + text-align: center; + padding: 1rem 0; + background-color: #333; + color: white; + margin-top: 2rem; +} \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/StaticSiteConstructAutoDataAttribute.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/StaticSiteConstructAutoDataAttribute.cs new file mode 100644 index 0000000..42fa1c3 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/StaticSiteConstructAutoDataAttribute.cs @@ -0,0 +1,18 @@ +using AutoFixture.Xunit3; +using LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations; + +namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes; + +/// +/// AutoData attribute for StaticSiteConstruct tests with customizable configuration. +/// +public class StaticSiteConstructAutoDataAttribute(bool includeApiDomain = true, bool includeAlternateDomains = true) : AutoDataAttribute(() => CreateFixture(includeApiDomain, includeAlternateDomains)) +{ + private static IFixture CreateFixture(bool includeApiDomain, bool includeAlternateDomains) + { + return BaseFixtureFactory.CreateFixture(fixture => + { + fixture.Customize(new StaticSiteConstructCustomization(includeApiDomain, includeAlternateDomains)); + }); + } +} \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/StaticSiteConstructCustomization.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/StaticSiteConstructCustomization.cs new file mode 100644 index 0000000..1344ed8 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/StaticSiteConstructCustomization.cs @@ -0,0 +1,23 @@ +using LayeredCraft.Cdk.Constructs.Models; +using LayeredCraft.Cdk.Constructs.Testing; + +namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations; + +/// +/// AutoFixture customization for StaticSiteConstructProps to provide realistic test data. +/// +public class StaticSiteConstructCustomization(bool includeApiDomain = true, bool includeAlternateDomains = true) + : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(transform => transform + .With(props => props.DomainName, "example.com") + .With(props => props.SiteSubDomain, "www") + .With(props => props.AssetPath, CdkTestHelper.GetTestAssetPath("TestAssets/static-site")) + .With(props => props.ApiDomain, includeApiDomain ? "api.example.com" : null) + .With(props => props.AlternateDomains, includeAlternateDomains + ? ["example.com", "alt.example.com"] + : [])); + } +} \ No newline at end of file diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/Testing/TestingHelpersTests.cs b/test/LayeredCraft.Cdk.Constructs.Tests/Testing/TestingHelpersTests.cs index 9ebd239..dc43db3 100644 --- a/test/LayeredCraft.Cdk.Constructs.Tests/Testing/TestingHelpersTests.cs +++ b/test/LayeredCraft.Cdk.Constructs.Tests/Testing/TestingHelpersTests.cs @@ -1,8 +1,10 @@ using Amazon.CDK.Assertions; using AwesomeAssertions; using LayeredCraft.Cdk.Constructs.Constructs; +using LayeredCraft.Cdk.Constructs.Models; using LayeredCraft.Cdk.Constructs.Testing; using LayeredCraft.Cdk.Constructs.Tests.TestKit.Extensions; +using LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations; namespace LayeredCraft.Cdk.Constructs.Tests.Testing; @@ -669,6 +671,167 @@ public void CdkTestHelper_ShouldWorkWithInternalConstructorAndConstructs() stack.IsInternalConstructor.Should().BeTrue(); } + + [Fact] + public void StaticSiteConstructCustomization_WithApiDomainIncluded_ShouldSetApiDomain() + { + // Arrange + var fixture = new Fixture(); + var customization = new StaticSiteConstructCustomization(includeApiDomain: true, includeAlternateDomains: false); + + // Act + customization.Customize(fixture); + var props = fixture.Create(); + + // Assert + props.ApiDomain.Should().Be("api.example.com"); + props.AlternateDomains.Should().BeEmpty(); + } + + [Fact] + public void StaticSiteConstructCustomization_WithApiDomainExcluded_ShouldNotSetApiDomain() + { + // Arrange + var fixture = new Fixture(); + var customization = new StaticSiteConstructCustomization(includeApiDomain: false, includeAlternateDomains: false); + + // Act + customization.Customize(fixture); + var props = fixture.Create(); + + // Assert + props.ApiDomain.Should().BeNull(); + props.AlternateDomains.Should().BeEmpty(); + } + + [Fact] + public void StaticSiteConstructCustomization_WithAlternateDomainsIncluded_ShouldSetAlternateDomains() + { + // Arrange + var fixture = new Fixture(); + var customization = new StaticSiteConstructCustomization(includeApiDomain: false, includeAlternateDomains: true); + + // Act + customization.Customize(fixture); + var props = fixture.Create(); + + // Assert + props.ApiDomain.Should().BeNull(); + props.AlternateDomains.Should().HaveCount(2); + props.AlternateDomains.Should().Contain("example.com"); + props.AlternateDomains.Should().Contain("alt.example.com"); + } + + [Fact] + public void StaticSiteConstructCustomization_WithAlternateDomainsExcluded_ShouldNotSetAlternateDomains() + { + // Arrange + var fixture = new Fixture(); + var customization = new StaticSiteConstructCustomization(includeApiDomain: false, includeAlternateDomains: false); + + // Act + customization.Customize(fixture); + var props = fixture.Create(); + + // Assert + props.ApiDomain.Should().BeNull(); + props.AlternateDomains.Should().BeEmpty(); + } + + [Fact] + public void StaticSiteConstructCustomization_WithBothIncluded_ShouldSetBothApiDomainAndAlternateDomains() + { + // Arrange + var fixture = new Fixture(); + var customization = new StaticSiteConstructCustomization(includeApiDomain: true, includeAlternateDomains: true); + + // Act + customization.Customize(fixture); + var props = fixture.Create(); + + // Assert + props.ApiDomain.Should().Be("api.example.com"); + props.AlternateDomains.Should().HaveCount(2); + props.AlternateDomains.Should().Contain("example.com"); + props.AlternateDomains.Should().Contain("alt.example.com"); + } + + [Fact] + public void StaticSiteConstructCustomization_ShouldAlwaysSetRequiredProperties() + { + // Arrange + var fixture = new Fixture(); + var customization = new StaticSiteConstructCustomization(includeApiDomain: false, includeAlternateDomains: false); + + // Act + customization.Customize(fixture); + var props = fixture.Create(); + + // Assert + props.DomainName.Should().Be("example.com"); + props.SiteSubDomain.Should().Be("www"); + props.AssetPath.Should().EndWith("TestAssets/static-site"); + Path.IsPathRooted(props.AssetPath).Should().BeTrue("Asset path should be absolute"); + } + + [Fact] + public void CdkTestHelper_ShouldCreateStaticSitePropsBuilderWithDefaults() + { + // Act + var builder = CdkTestHelper.CreateStaticSitePropsBuilder(); + var props = builder.Build(); + + // Assert + props.DomainName.Should().Be("example.com"); + props.SiteSubDomain.Should().Be("www"); + props.AssetPath.Should().EndWith("TestAssets/static-site"); + Path.IsPathRooted(props.AssetPath).Should().BeTrue("Asset path should be absolute"); + props.ApiDomain.Should().BeNull(); + props.AlternateDomains.Should().BeEmpty(); + } + + [Fact] + public void CdkTestHelper_ShouldCreateStaticSitePropsBuilderWithCustomAssetPath() + { + // Arrange + var customPath = CdkTestHelper.GetTestAssetPath("TestAssets/custom-site"); + + // Act + var builder = CdkTestHelper.CreateStaticSitePropsBuilder(customPath); + var props = builder.Build(); + + // Assert + props.AssetPath.Should().Be(customPath); + props.AssetPath.Should().EndWith("TestAssets/custom-site"); + } + + [Fact] + public void PropsBuilder_ShouldConfigureSnapStart() + { + // Act + var props = CdkTestHelper.CreatePropsBuilder(AssetPathExtensions.GetTestLambdaZipPath()) + .WithFunctionName("snapstart-test") + .WithSnapStart(true) + .Build(); + + // Assert + props.FunctionName.Should().Be("snapstart-test"); + props.EnableSnapStart.Should().BeTrue(); + } + + [Fact] + public void PropsBuilder_ShouldDisableSnapStart() + { + // Act + var props = CdkTestHelper.CreatePropsBuilder(AssetPathExtensions.GetTestLambdaZipPath()) + .WithFunctionName("no-snapstart-test") + .WithSnapStart(false) + .Build(); + + // Assert + props.FunctionName.Should().Be("no-snapstart-test"); + props.EnableSnapStart.Should().BeFalse(); + } } // Test helper interface for custom props testing