Initial commit.
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
import Configuration
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import Website
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("App executable")
|
||||
struct AppTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
// Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
|
||||
private let immutableExtensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js
|
||||
]
|
||||
|
||||
// Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle
|
||||
// at build time — the repository tree itself is off limits to Xcode's test runner.
|
||||
private let staticFilesPath: String = {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: "Static",
|
||||
withExtension: nil
|
||||
) else {
|
||||
preconditionFailure("The static files are missing from the test bundle.")
|
||||
}
|
||||
|
||||
return url.path
|
||||
}()
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `landing page to be served at root`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(body.contains("Hello world!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to answer a head request`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .head
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `health check to be served at the health path`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `readiness check to be served at the readiness path`() async throws {
|
||||
// Live mode runs the application's service group, so the `Fluent` service starts before the
|
||||
// request and shuts its connection pool down after — the router-only mode never would.
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.live) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ready"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: StaticFile.allCases)
|
||||
func `static files to be served`(
|
||||
staticFile file: StaticFile
|
||||
) async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
for fileExtension in file.fileExtensions {
|
||||
try await client.execute(
|
||||
uri: "/\(file.relativePath(for: fileExtension))",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == fileExtension.contentType)
|
||||
|
||||
let cacheControl = try #require(response.headers[.cacheControl])
|
||||
|
||||
#expect(cacheControl.contains("public") == true)
|
||||
#expect(cacheControl.contains("max-age=") == true)
|
||||
|
||||
if immutableExtensions.contains(fileExtension) {
|
||||
#expect(cacheControl.contains("immutable") == true)
|
||||
} else if fileExtension == .txt {
|
||||
#expect(cacheControl.contains("must-revalidate") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `versioned asset URL to be served`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/css/shared.css?v=0123456789abcdef",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/css")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to reference fingerprinted assets`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/shared.css?v="))
|
||||
#expect(body.contains("/js/shared.js?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to revalidate with an entity tag`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||
|
||||
return try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
#expect(response.headers[.eTag] == eTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `responses to vary on language and encoding`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let vary = try #require(response.headers[.vary])
|
||||
|
||||
#expect(vary.contains("Accept-Language"))
|
||||
#expect(vary.contains("Accept-Encoding"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `response to be compressed when the client supports it`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.acceptEncoding: "gzip"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentEncoding] == "gzip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `response to not be compressed when the client does not support it`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentEncoding] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to be served when not found`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(body.contains("Page Not Found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to reference fingerprinted assets`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/not-found.css?v="))
|
||||
#expect(body.contains("/js/shared.js?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to be served without revalidation headers`() async throws {
|
||||
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||
// invite revalidation with an entity tag or a cache policy.
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.eTag] == nil)
|
||||
#expect(response.headers[.cacheControl] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to vary on language and encoding`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let vary = try #require(response.headers[.vary])
|
||||
|
||||
#expect(vary.contains("Accept-Language"))
|
||||
#expect(vary.contains("Accept-Encoding"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to revalidate a conditional head request`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .head,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the landing page`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
|
||||
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
|
||||
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
|
||||
#expect(response.headers[.strictTransportSecurity] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the error page`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strict-transport-security to be applied when configured`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath,
|
||||
strictTransportSecurity: "max-age=31536000; includeSubDomains"
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.strictTransportSecurity] == "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AppTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func app(
|
||||
staticFilesPath: String,
|
||||
strictTransportSecurity: String? = nil
|
||||
) async throws -> some ApplicationProtocol {
|
||||
try await application(
|
||||
reader: reader(
|
||||
staticFilesPath: staticFilesPath,
|
||||
strictTransportSecurity: strictTransportSecurity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func reader(
|
||||
staticFilesPath: String,
|
||||
strictTransportSecurity: String? = nil
|
||||
) -> ConfigReader {
|
||||
ConfigReader(providers: [{
|
||||
if let strictTransportSecurity {
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
.Security.strictTransportSecurity: .init(stringLiteral: strictTransportSecurity)
|
||||
])
|
||||
} else {
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
])
|
||||
}
|
||||
}()])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Configuration
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import Website
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("ConfigReader properties")
|
||||
struct ConfigReaderPropertiesTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `analytics to be omitted by default`() {
|
||||
// The template ships no website identifier, so an unconfigured deployment embeds no tracker at all.
|
||||
#expect(.Analytics.websiteID == "")
|
||||
#expect(reader().analytics == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to be enabled by setting the website id alone`() throws {
|
||||
let analytics = try #require(reader(values: [
|
||||
.Analytics.websiteID: "0000-website-id"
|
||||
]).analytics)
|
||||
|
||||
#expect(analytics.scriptURL == .Analytics.scriptURL)
|
||||
#expect(analytics.websiteID == "0000-website-id")
|
||||
#expect(analytics.domains.isEmpty)
|
||||
#expect(analytics.excludeHash)
|
||||
#expect(analytics.doNotTrack)
|
||||
#expect(analytics.performance)
|
||||
#expect(!analytics.recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to switch recorder mode on when configured`() throws {
|
||||
// Session recording is the most invasive thing the tracker does, so it is opted into rather than out of.
|
||||
let analytics = try #require(reader(values: [
|
||||
.Analytics.websiteID: "0000-website-id",
|
||||
.Analytics.recorder: true
|
||||
]).analytics)
|
||||
|
||||
#expect(analytics.recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to override the website id and domains when configured`() throws {
|
||||
let analytics = try #require(reader(values: [
|
||||
.Analytics.websiteID: "custom-website-id",
|
||||
.Analytics.domains: "staging.example.com"
|
||||
]).analytics)
|
||||
|
||||
#expect(analytics.websiteID == "custom-website-id")
|
||||
#expect(analytics.domains == "staging.example.com")
|
||||
#expect(analytics.scriptURL == .Analytics.scriptURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to be omitted when the website id is cleared`() {
|
||||
#expect(reader(values: [.Analytics.websiteID: ""]).analytics == nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ConfigReaderPropertiesTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds a configuration reader over the given in-memory values alone.
|
||||
func reader(
|
||||
values: [AbsoluteConfigKey: ConfigValue] = [:]
|
||||
) -> ConfigReader {
|
||||
ConfigReader(providers: [
|
||||
InMemoryProvider(values: values)
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../Resources/Static
|
||||
@@ -0,0 +1,82 @@
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"StaticFile enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias File = StaticFile
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileExtensions
|
||||
))
|
||||
func `file extensions`(
|
||||
for file: File,
|
||||
expects extensions: [AssetExtension]
|
||||
) {
|
||||
#expect(file.fileExtensions == extensions)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileNames
|
||||
))
|
||||
func `file name`(
|
||||
for file: File,
|
||||
expects fileName: String
|
||||
) {
|
||||
#expect(file.fileName == fileName)
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
@Test
|
||||
func `all cases`() {
|
||||
#expect(File.allCases.count == 11)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let fileExtensions: [[AssetExtension]] = [
|
||||
[.png],
|
||||
[.ico],
|
||||
[.svg],
|
||||
[.png],
|
||||
[.png],
|
||||
[.css, .js],
|
||||
[.css, .js],
|
||||
[.txt],
|
||||
[.css, .js],
|
||||
[.webmanifest],
|
||||
[.xml]
|
||||
]
|
||||
static let fileNames: [String] = [
|
||||
"apple-touch-icon",
|
||||
"favicon",
|
||||
"icon",
|
||||
"icon-192",
|
||||
"icon-512",
|
||||
"index",
|
||||
"not-found",
|
||||
"robots",
|
||||
"shared",
|
||||
"site",
|
||||
"sitemap"
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"IndexPage page",
|
||||
.tags(.page)
|
||||
)
|
||||
struct IndexPageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders its markup`() {
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#expect(html.contains(#"lang="en""#))
|
||||
#expect(html.contains("/css/shared.css"))
|
||||
#expect(html.contains("/css/index.css"))
|
||||
#expect(html.contains("/favicon.ico"))
|
||||
#expect(html.contains("/icon.svg"))
|
||||
#expect(html.contains("/apple-touch-icon.png"))
|
||||
#expect(html.contains("/site.webmanifest"))
|
||||
#expect(html.contains(#"media="(prefers-color-scheme: dark)""#))
|
||||
#expect(html.contains("Hello world!"))
|
||||
#expect(html.contains("/js/shared.js"))
|
||||
#expect(html.contains("/js/index.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders versioned asset URLs when given a version`() {
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en"),
|
||||
assetVersion: "0123456789abcdef"
|
||||
).render()
|
||||
|
||||
#expect(html.contains("/css/shared.css?v=0123456789abcdef"))
|
||||
#expect(html.contains("/css/index.css?v=0123456789abcdef"))
|
||||
#expect(html.contains("/js/shared.js?v=0123456789abcdef"))
|
||||
#expect(html.contains("/js/index.js?v=0123456789abcdef"))
|
||||
#expect(html.contains("/favicon.ico?v=0123456789abcdef"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"NotFoundPage page",
|
||||
.tags(.page)
|
||||
)
|
||||
struct NotFoundPageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders its markup`() {
|
||||
let html = NotFoundPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#expect(html.contains(#"lang="en""#))
|
||||
#expect(html.contains("Page Not Found"))
|
||||
#expect(html.contains("Sorry, but the page you were trying to view does not exist."))
|
||||
#expect(html.contains("/css/shared.css"))
|
||||
#expect(html.contains("/css/not-found.css"))
|
||||
#expect(html.contains("/js/not-found.js"))
|
||||
#expect(html.contains("/js/shared.js"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"HealthController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct HealthControllerTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `serves the status payload at the health path`() async throws {
|
||||
try await app(
|
||||
probe: nil
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves ready at the readiness path when the database is reachable`() async throws {
|
||||
let service = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
try await app(
|
||||
probe: Probe(fluent: fluent)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ready"}"#)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves unavailable at the readiness path when the database is unreachable`() async throws {
|
||||
// Port 1 on the loopback interface has nothing listening, so the probe's connection is refused
|
||||
// immediately instead of timing out.
|
||||
let service = try Service(
|
||||
driver: .postgres(
|
||||
.init(
|
||||
host: "127.0.0.1",
|
||||
port: 1,
|
||||
name: "unreachable",
|
||||
username: "nobody",
|
||||
password: "nothing",
|
||||
tls: .off,
|
||||
maxConnectionsPerEventLoop: 1,
|
||||
poolTimeout: .seconds(10)
|
||||
)
|
||||
),
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
try await app(
|
||||
probe: Probe(fluent: fluent)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .serviceUnavailable)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"unavailable"}"#)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not serve the readiness path without a probe`() async throws {
|
||||
try await app(probe: nil).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HealthControllerTests {
|
||||
|
||||
/// Builds a test application serving the ``HealthController`` routes for the given probe.
|
||||
/// - Parameter probe: the probe supplied to the controller, or `nil` for liveness only.
|
||||
/// - Returns: the configured test application.
|
||||
func app(
|
||||
probe: Probe?
|
||||
) -> some ApplicationProtocol {
|
||||
Application(router: {
|
||||
let router = Router()
|
||||
|
||||
router.addRoutes(HealthController<BasicRequestContext>(probe: probe).routes)
|
||||
|
||||
return router
|
||||
}())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"RootController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct RootControllerTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
}
|
||||
|
||||
router.addRoutes(RootController<WebsiteRequestContext>().routes)
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `serves the landing page at the root path`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.headers[.contentLanguage] == "en")
|
||||
#expect(response.headers[.vary] == "Accept-Language")
|
||||
#expect(body.contains("Hello world!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves the landing page with revalidation headers`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let eTag = try #require(response.headers[.eTag])
|
||||
|
||||
#expect(eTag.hasPrefix(#"W/""#))
|
||||
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `revalidates a matching conditional request with a 304`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.headers[.eTag] == eTag)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves the full page to a non-matching conditional request`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: #"W/"0123456789abcdef""#]
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(body.contains("Hello world!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders versioned asset URLs when given a version`() async throws {
|
||||
try await app(
|
||||
assetVersion: "0123456789abcdef"
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/index.css?v=0123456789abcdef"))
|
||||
#expect(body.contains("/js/index.js?v=0123456789abcdef"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders unversioned asset URLs by default`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"href="/css/index.css""#))
|
||||
#expect(!body.contains("?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `embeds no analytics tracker by default`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(!body.contains("data-website-id"))
|
||||
#expect(!body.contains("analytics"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `embeds the analytics tracker when one is configured`() async throws {
|
||||
try await app(
|
||||
analytics: .init(
|
||||
scriptURL: "https://analytics.example.com/script",
|
||||
websiteID: "0000-website-id",
|
||||
domains: "example.com"
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"<link rel="preconnect" href="https://analytics.example.com">"#))
|
||||
#expect(body.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com""#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RootControllerTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose root controller appends the given version token to the landing
|
||||
/// page's asset URLs and embeds the given analytics tracker.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
/// - Returns: the configured application.
|
||||
func app(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
}
|
||||
|
||||
router.addRoutes(RootController<WebsiteRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
).routes)
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Testing
|
||||
|
||||
extension Tag {
|
||||
/// Tests exercising a controller of the Website library.
|
||||
@Tag static var controller: Tag
|
||||
/// Tests exercising an enumeration of the Website library.
|
||||
@Tag static var enumeration: Tag
|
||||
/// Tests exercising a page of the Website library.
|
||||
@Tag static var page: Tag
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "F3231E68-357B-4ED7-98E9-F5767AB17024",
|
||||
"name" : "Test Scheme Action",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"performanceAntipatternCheckerEnabled" : true,
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "Website",
|
||||
"name" : "Website"
|
||||
},
|
||||
"testInteropMode" : "complete"
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "WebsiteTests",
|
||||
"name" : "WebsiteTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "WebsiteLibraryTests",
|
||||
"name" : "WebsiteLibraryTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Persistence",
|
||||
"identifier" : "PersistenceTests",
|
||||
"name" : "PersistenceTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Localization",
|
||||
"identifier" : "LocalizationTests",
|
||||
"name" : "LocalizationTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Infrastructure",
|
||||
"identifier" : "InfrastructureTests",
|
||||
"name" : "InfrastructureTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Utility",
|
||||
"identifier" : "UtilityTests",
|
||||
"name" : "UtilityTests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
Reference in New Issue
Block a user