Renamed the Web package as Infrastructure (#25)

This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project.

To provide further details about the work:

* Infrastructure
  * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags.
  * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses.
  * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements.
  * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext.
  * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods.
  * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache.

Reviewed-on: rock-n-code/loud-amsterdam#25
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-07-23 01:04:37 +00:00
committed by javier
parent a868275347
commit cdded06ba3
58 changed files with 2844 additions and 519 deletions
@@ -0,0 +1,76 @@
import Testing
@testable import Infrastructure
@Suite("FNV1aHash type")
struct FNV1aHashTests {
// MARK: Functional tests
@Test(arguments: [
("", "cbf29ce484222325"),
("a", "af63dc4c8601ec8c"),
("b", "af63df4c8601f1a5"),
("foobar", "85944171f73967e8"),
])
func `matches the published FNV-1a 64-bit test vectors`(
input: String,
digest: String
) {
var hash = FNV1aHash()
hash.combine(input.utf8)
#expect(hash.digest == digest)
}
@Test
func `pads the digest to sixteen characters`() {
var hash = FNV1aHash()
// "aa" hashes to 0x089c4307b54596b7, whose leading zero the digest must keep.
hash.combine("aa".utf8)
#expect(hash.digest == "089c4307b54596b7")
#expect(hash.digest.count == 16)
}
@Test
func `hashes incrementally combined bytes as one stream`() {
var combined = FNV1aHash()
var whole = FNV1aHash()
combined.combine("foo".utf8)
combined.combine("bar".utf8)
whole.combine("foobar".utf8)
#expect(combined.digest == whole.digest)
}
@Test
func `distinguishes the order of the combined bytes`() {
var forward = FNV1aHash()
var backward = FNV1aHash()
forward.combine("ab".utf8)
backward.combine("ba".utf8)
#expect(forward.digest != backward.digest)
}
@Test
func `digests without consuming the running hash`() {
var hash = FNV1aHash()
hash.combine("foo".utf8)
let first = hash.digest
#expect(hash.digest == first)
hash.combine("bar".utf8)
#expect(hash.digest != first)
}
}
@@ -0,0 +1,71 @@
import Testing
@testable import Infrastructure
@Suite("AssetExtension enumeration")
struct AssetExtensionTests {
// MARK: Computed tests
@Test(arguments: zip(
Self.extensions,
Self.contentTypes
))
func `content type`(
for fileExtension: AssetExtension,
expects contentType: String
) {
#expect(fileExtension.contentType == contentType)
}
@Test(arguments: zip(
Self.extensions,
Self.subdirectories
))
func `subdirectory`(
for fileExtension: AssetExtension,
expects subdirectory: String?
) {
#expect(fileExtension.subdirectory == subdirectory)
}
}
// MARK: - Helpers
private extension AssetExtensionTests {
// MARK: Constants
static let extensions: [AssetExtension] = [
.css,
.js,
.png,
.ico,
.svg,
.txt,
.webmanifest,
.xml
]
static let contentTypes: [String] = [
"text/css",
"text/javascript",
"image/png",
"image/vnd.microsoft.icon",
"image/svg+xml",
"text/plain",
"application/manifest+json",
"application/xml"
]
static let subdirectories: [String?] = [
"css",
"js",
nil,
nil,
nil,
nil,
nil,
nil
]
}
@@ -0,0 +1,171 @@
import Foundation
import Testing
@testable import Infrastructure
@Suite("FingerprintAssets method")
struct FingerprintAssetsTests {
// MARK: Properties
private let fingerprint = FingerprintAssets()
// MARK: Functional tests
@Test
func `fingerprints the files under a directory`() throws {
let directory = try makeDirectory(files: [
"css/site.css": "body { margin: 0; }",
"robots.txt": "User-agent: *"
])
defer {
removeDirectory(directory)
}
let token = try #require(fingerprint(directory.path))
#expect(token.count == 16)
#expect(token.allSatisfy { $0.isHexDigit })
}
@Test
func `agrees across directories with identical contents`() throws {
let files = [
"css/site.css": "body { margin: 0; }",
"js/site.js": "console.log(1);"
]
let first = try makeDirectory(files: files)
let second = try makeDirectory(files: files)
defer {
removeDirectory(first)
removeDirectory(second)
}
#expect(fingerprint(first.path) == fingerprint(second.path))
}
@Test
func `changes the token when a file's contents change`() throws {
let directory = try makeDirectory(files: [
"css/site.css": "body { margin: 0; }"
])
defer {
removeDirectory(directory)
}
let before = fingerprint(directory.path)
try "body { margin: 1px; }".write(
to: directory.appendingPathComponent("css/site.css"),
atomically: true,
encoding: .utf8
)
#expect(fingerprint(directory.path) != before)
}
@Test
func `changes the token when a file is renamed`() throws {
let contents = "body { margin: 0; }"
let first = try makeDirectory(files: ["css/site.css": contents])
let second = try makeDirectory(files: ["css/main.css": contents])
defer {
removeDirectory(first)
removeDirectory(second)
}
#expect(fingerprint(first.path) != fingerprint(second.path))
}
@Test
func `changes the token when a file is added`() throws {
let directory = try makeDirectory(files: [
"css/site.css": "body { margin: 0; }"
])
defer {
removeDirectory(directory)
}
let before = fingerprint(directory.path)
try "console.log(1);".write(
to: directory.appendingPathComponent("site.js"),
atomically: true,
encoding: .utf8
)
#expect(fingerprint(directory.path) != before)
}
@Test
func `returns nil for a directory without files`() throws {
let directory = try makeDirectory(files: [:])
defer {
removeDirectory(directory)
}
#expect(fingerprint(directory.path) == nil)
}
@Test
func `returns nil for a missing directory`() {
let missing = FileManager.default.temporaryDirectory
.appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)")
#expect(fingerprint(missing.path) == nil)
}
}
// MARK: - Helpers
private extension FingerprintAssetsTests {
// MARK: Methods
/// Creates a unique temporary directory holding the given files, keyed by relative path.
/// - Parameter files: the files to create, keyed by their path relative to the directory.
/// - Returns: the URL of the created directory.
func makeDirectory(
files: [String: String]
) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
for (relativePath, contents) in files {
let file = directory.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(
at: file.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try contents.write(
to: file,
atomically: true,
encoding: .utf8
)
}
return directory
}
/// Removes a temporary directory created by ``makeDirectory(files:)``.
/// - Parameter directory: the URL of the directory to remove.
func removeDirectory(
_ directory: URL
) {
try? FileManager.default.removeItem(at: directory)
}
}
@@ -0,0 +1,69 @@
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdTesting
import NIOCore
import Testing
@testable import Infrastructure
@Suite("LocalizationMiddleware middleware", .tags(.middleware))
struct LocalizationMiddlewareTests {
// MARK: Constants
private let app: Application = .init(router: {
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
}
router.get("language") { _, context in
context.language
}
return router
}())
// MARK: Functional tests
@Test
func `negotiates a supported language from the header`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/language",
method: .get,
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
) { response in
#expect(String(buffer: response.body) == "de")
}
}
}
@Test
func `falls back to the default without a header`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/language",
method: .get
) { response in
#expect(String(buffer: response.body) == "en")
}
}
}
@Test
func `falls back to the default for an unsupported language`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/language",
method: .get,
headers: [.acceptLanguage: "fr-FR,fr;q=0.9"]
) { response in
#expect(String(buffer: response.body) == "en")
}
}
}
}
@@ -0,0 +1,191 @@
import Foundation
import Hummingbird
import HummingbirdTesting
import NIOCore
import Testing
@testable import Infrastructure
@Suite("NotFoundMiddleware middleware", .tags(.middleware))
struct NotFoundMiddlewareTests {
// MARK: Constants
private let app: Application = .init(router: {
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
NotFoundMiddleware(bundle: .module) {
StubPage(locale: $0)
}
}
router.get("hello") { _, _ in
"Hello!"
}
router.get("boom") { _, _ -> String in
throw HTTPError(.badRequest)
}
return router
}())
// MARK: Functional tests
@Test
func `renders the error page for an unmatched request`() async throws {
try await app.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(response.headers[.contentLanguage] == "en")
#expect(response.headers[.vary] == "Accept-Language")
#expect(body.contains("Stub content"))
}
}
}
@Test
func `renders the error page in the negotiated language`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/this-path-does-not-exist",
method: .get,
headers: [.acceptLanguage: "de-DE,de;q=0.9"]
) { response in
#expect(response.status == .notFound)
#expect(response.headers[.contentLanguage] == "de")
}
}
}
@Test
func `passes a matched response through untouched`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.body == ByteBuffer(string: "Hello!"))
}
}
}
@Test
func `rethrows a non-not-found error unchanged`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/boom",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .badRequest)
#expect(!body.contains("Stub content"))
}
}
}
@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: "/this-path-does-not-exist",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(body.contains("/css/stub.css?v=0123456789abcdef"))
#expect(body.contains("/js/stub.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: "/this-path-does-not-exist",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(body.contains(#"href="/css/stub.css""#))
#expect(!body.contains("?v="))
}
}
}
@Test
func `serves the error page 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.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 `serves the full error page to a conditional request`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/this-path-does-not-exist",
method: .get,
headers: [.ifNoneMatch: "*"]
) { response in
let body = String(buffer: response.body)
#expect(response.status == .notFound)
#expect(body.contains("Stub content"))
}
}
}
}
// MARK: - Helpers
private extension NotFoundMiddlewareTests {
// MARK: Methods
/// Builds an application whose not-found middleware appends the given version token to the
/// error page's asset URLs.
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
/// - Returns: the configured application.
func app(
assetVersion: String?
) -> some ApplicationProtocol {
let router = Router(context: StubRequestContext.self)
router.addMiddleware {
LocalizationMiddleware(bundle: .module)
NotFoundMiddleware(bundle: .module) {
StubPage(
locale: $0,
assetVersion: assetVersion
)
}
}
return Application(router: router)
}
}
@@ -0,0 +1,170 @@
import Hummingbird
import HummingbirdTesting
import Testing
@testable import Infrastructure
@Suite("RateLimitMiddleware middleware", .tags(.middleware))
struct RateLimitMiddlewareTests {
// MARK: Functional tests
@Test
func `admits requests within the limit`() async throws {
try await app(
configuration: .init(limit: 3)
).test(.router) { client in
for _ in 1 ... 3 {
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
}
}
}
}
@Test
func `rejects a request over the limit with a retry-after header`() async throws {
try await app(
configuration: .init(limit: 2)
).test(.router) { client in
for _ in 1 ... 2 {
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
}
}
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .tooManyRequests)
let retryAfter = try #require(response.headers[.retryAfter])
#expect(try #require(Int(retryAfter)) >= 1)
}
}
}
@Test
func `admits requests again once the window resets`() async throws {
try await app(
configuration: .init(
limit: 1,
window: .milliseconds(50)
)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
}
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .tooManyRequests)
}
try await Task.sleep(for: .milliseconds(100))
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
}
}
}
@Test
func `separates clients by their forwarded address when trusted`() async throws {
try await app(
configuration: .init(
limit: 1,
trustForwardedFor: true
)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get,
headers: [.xForwardedFor: "203.0.113.7"]
) { response in
#expect(response.status == .ok)
}
try await client.execute(
uri: "/hello",
method: .get,
headers: [.xForwardedFor: "203.0.113.8"]
) { response in
#expect(response.status == .ok)
}
// The first entry names the client; the appended proxy hop must not change its key.
try await client.execute(
uri: "/hello",
method: .get,
headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"]
) { response in
#expect(response.status == .tooManyRequests)
}
}
}
@Test
func `ignores the forwarded address when not trusted`() async throws {
try await app(
configuration: .init(limit: 1)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get,
headers: [.xForwardedFor: "203.0.113.7"]
) { response in
#expect(response.status == .ok)
}
// Without trust (and without a connection address in router-only testing), every client
// shares one bucket, so a rotated header must not mint a fresh budget.
try await client.execute(
uri: "/hello",
method: .get,
headers: [.xForwardedFor: "203.0.113.8"]
) { response in
#expect(response.status == .tooManyRequests)
}
}
}
}
// MARK: - Helpers
private extension RateLimitMiddlewareTests {
// MARK: Methods
/// Builds an application whose router applies the rate-limit middleware ahead of a single
/// `/hello` route returning a plain body.
func app(
configuration: RateLimitMiddleware<BasicRequestContext>.Configuration
) -> some ApplicationProtocol {
let router = Router()
router.addMiddleware {
RateLimitMiddleware(configuration: configuration)
}
router.get("hello") { _, _ in
"Hello!"
}
return Application(router: router)
}
}
@@ -0,0 +1,156 @@
import Hummingbird
import HummingbirdTesting
import Testing
@testable import Infrastructure
@Suite("SecurityHeadersMiddleware middleware", .tags(.middleware))
struct SecurityHeadersMiddlewareTests {
// MARK: Functional tests
@Test
func `applies the default security headers to a response`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
#expect(response.headers[.frameOptions] == .Security.frameOptions)
#expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
#expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
}
}
}
@Test
func `omits strict-transport-security by default`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.strictTransportSecurity] == nil)
}
}
}
@Test
func `applies strict-transport-security when configured`() async throws {
let value = "max-age=31536000; includeSubDomains"
try await app(
configuration: .init(strictTransportSecurity: value)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.strictTransportSecurity] == value)
}
}
}
@Test
func `applies a custom header value`() async throws {
let value = "default-src 'none'"
try await app(
configuration: .init(contentSecurityPolicy: value)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.contentSecurityPolicy] == value)
}
}
}
@Test
func `omits a header whose configured value is nil`() async throws {
try await app(
configuration: .init(contentTypeOptions: nil)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.xContentTypeOptions] == nil)
}
}
}
@Test
func `replaces an existing header value set downstream`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/weak",
method: .get
) { response in
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
}
}
}
@Test
func `applies the security headers to an error response`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/throws",
method: .get
) { response in
#expect(response.status == .badRequest)
#expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy)
#expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions)
#expect(response.headers[.frameOptions] == .Security.frameOptions)
#expect(response.headers[.referrerPolicy] == .Security.referrerPolicy)
#expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy)
}
}
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddlewareTests {
// MARK: Methods
/// Builds an application whose router applies the security-headers middleware ahead of three
/// routes: `/hello` returns a plain body, `/weak` returns a response that already carries a
/// deliberately weak `X-Content-Type-Options` value for the middleware to override, and
/// `/throws` fails with an `HTTPError` the way the controllers do on invalid input.
func app(
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
) -> some ApplicationProtocol {
let router = Router()
router.addMiddleware {
SecurityHeadersMiddleware(configuration: configuration)
}
router.get("hello") { _, _ in
"Hello!"
}
router.get("weak") { _, _ -> Response in
var response = Response(status: .ok)
response.headers[.xContentTypeOptions] = "weak"
return response
}
router.get("throws") { _, _ -> Response in
throw HTTPError(.badRequest)
}
return Application(router: router)
}
}
@@ -0,0 +1,131 @@
import HTTPTypes
import Hummingbird
import HummingbirdTesting
import Testing
@testable import Infrastructure
@Suite("VaryMiddleware middleware", .tags(.middleware))
struct VaryMiddlewareTests {
// MARK: Functional tests
@Test
func `adds the default field to a response without a vary header`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/plain",
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.headers[.vary] == "Accept-Encoding")
}
}
}
@Test
func `appends to an existing vary header`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/localized",
method: .get
) { response in
#expect(response.headers[.vary] == "Accept-Language, Accept-Encoding")
}
}
}
@Test
func `does not duplicate a name already present`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/encoded",
method: .get
) { response in
#expect(response.headers[.vary] == "Accept-Encoding")
}
}
}
@Test
func `matches an existing name regardless of its casing`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/lowercased",
method: .get
) { response in
#expect(response.headers[.vary] == "accept-encoding")
}
}
}
@Test
func `normalizes the whitespace of an existing list`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/spaced",
method: .get
) { response in
#expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding")
}
}
}
@Test
func `appends every configured field`() async throws {
try await app(
fields: [.acceptEncoding, .acceptLanguage]
).test(.router) { client in
try await client.execute(
uri: "/plain",
method: .get
) { response in
#expect(response.headers[.vary] == "Accept-Encoding, Accept-Language")
}
}
}
}
// MARK: - Helpers
private extension VaryMiddlewareTests {
// MARK: Methods
/// Builds an application whose router applies the vary middleware ahead of routes whose
/// responses carry different `Vary` starting points: `/plain` none, `/localized` an
/// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase
/// `accept-encoding`, and `/spaced` a list with irregular whitespace.
func app(
fields: [HTTPField.Name] = [.acceptEncoding]
) -> some ApplicationProtocol {
let router = Router()
router.addMiddleware {
VaryMiddleware(fields: fields)
}
router.get("plain") { _, _ in
"Hello!"
}
for (path, vary) in [
("localized", "Accept-Language"),
("encoded", "Accept-Encoding"),
("lowercased", "accept-encoding"),
("spaced", "Accept-Language , User-Agent"),
] {
router.get(RouterPath(path)) { _, _ -> Response in
var response = Response(status: .ok)
response.headers[.vary] = vary
return response
}
}
return Application(router: router)
}
}
@@ -0,0 +1,79 @@
import Testing
@testable import Infrastructure
@Suite("Asset protocol")
struct AssetTests {
// MARK: Properties
private let image = StubAsset(
fileExtensions: [.png],
fileName: "icon"
)
private let shared = StubAsset(
fileExtensions: [.css, .js],
fileName: "shared"
)
// MARK: Method tests
@Test
func `relative path nests the file inside its extension's sub-directory`() {
#expect(shared.relativePath(for: .css) == "css/shared.css")
#expect(shared.relativePath(for: .js) == "js/shared.js")
}
@Test
func `relative path keeps the file at the root without a sub-directory`() {
#expect(image.relativePath(for: .png) == "icon.png")
}
@Test
func `url path prefixes the relative path with a slash`() {
#expect(shared.urlPath(for: .css) == "/css/shared.css")
#expect(image.urlPath(for: .png) == "/icon.png")
}
@Test
func `url path appends a version token as a query parameter`() {
#expect(shared.urlPath(
for: .css,
version: "0123456789abcdef"
) == "/css/shared.css?v=0123456789abcdef")
}
@Test(arguments: [nil, ""] as [String?])
func `url path without a version`(
version: String?
) {
#expect(shared.urlPath(
for: .css,
version: version
) == "/css/shared.css")
}
@Test(arguments: [
"",
".",
"Resources/Static"
])
func `path relative to`(
_ basePath: String
) {
for fileExtension in shared.fileExtensions {
let pathRelativeToBasePath = shared.path(
relativeTo: basePath,
for: fileExtension
)
let relativePath = shared.relativePath(for: fileExtension)
if basePath.isEmpty {
#expect(pathRelativeToBasePath == relativePath)
} else {
#expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)")
}
}
}
}
@@ -0,0 +1,62 @@
import Elementary
import Foundation
import Testing
@testable import Infrastructure
@Suite("Page protocol", .tags(.page))
struct PageTests {
// MARK: Functional tests
@Test
func `assembles the document around the page's parts`() {
let html = StubPage().render()
#expect(html.contains("<title>Stub Page</title>"))
#expect(html.contains(#"lang="en""#))
#expect(html.contains(#"name="viewport""#))
#expect(html.contains(#"<meta name="stub" content="marker">"#))
#expect(html.contains(#"<link rel="stylesheet" href="/css/stub.css">"#))
#expect(html.contains(#"<script src="/js/stub.js"></script>"#))
#expect(html.contains("Stub content"))
}
@Test
func `places the metadata between the viewport and the stylesheets`() throws {
let html = StubPage().render()
let viewport = try #require(html.range(of: #"name="viewport""#))
let metadata = try #require(html.range(of: #"name="stub""#))
let stylesheet = try #require(html.range(of: "/css/stub.css"))
#expect(viewport.lowerBound < metadata.lowerBound)
#expect(metadata.lowerBound < stylesheet.lowerBound)
}
@Test
func `renders the scripts after the content`() throws {
let html = StubPage().render()
let content = try #require(html.range(of: "Stub content"))
let script = try #require(html.range(of: "/js/stub.js"))
#expect(content.lowerBound < script.lowerBound)
}
@Test
func `appends the version token to the asset URLs`() {
let html = StubPage(assetVersion: "0123456789abcdef").render()
#expect(html.contains("/css/stub.css?v=0123456789abcdef"))
#expect(html.contains("/js/stub.js?v=0123456789abcdef"))
}
@Test
func `derives the document language from the locale`() {
let html = StubPage(locale: .init(identifier: "de-DE")).render()
#expect(html.contains(#"lang="de""#))
}
}
@@ -0,0 +1,23 @@
{
"sourceLanguage" : "en",
"strings" : {
"test.greeting" : {
"comment" : "Fixture string used by the Infrastructure test suite.",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hallo"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hello"
}
}
}
}
},
"version" : "1.0"
}
@@ -0,0 +1,11 @@
import Infrastructure
/// An ``Asset`` with a fixed file name and set of extensions.
struct StubAsset: Asset {
// MARK: Properties
let fileExtensions: [AssetExtension]
let fileName: String
}
@@ -0,0 +1,26 @@
import Hummingbird
import Infrastructure
/// A ``LocalizedRequestContext`` carrying the core storage and the negotiated language only.
struct StubRequestContext: LocalizedRequestContext {
// MARK: Properties
/// The core request context storage Hummingbird requires.
var coreContext: CoreRequestContextStorage
/// The language identifier negotiated for the request.
var language: String
// MARK: Initializers
/// Creates a request context for the given source.
/// - Parameter source: the source the context is initialized from.
init(
source: Source
) {
self.coreContext = .init(source: source)
self.language = ""
}
}
@@ -0,0 +1,8 @@
import Testing
extension Tag {
/// Tests exercising a middleware of the Infrastructure package.
@Tag static var middleware: Tag
/// Tests exercising the page scaffolding of the Infrastructure package.
@Tag static var page: Tag
}
@@ -0,0 +1,66 @@
import Elementary
import Foundation
import Infrastructure
/// A ``Page`` with fixed content, metadata, and stub assets.
struct StubPage: Page {
// MARK: Properties
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
/// The locale the page content is localized to.
let locale: Locale
// MARK: Initializers
/// Creates a stub page.
/// - Parameters:
/// - locale: the locale the page content is localized to. Defaults to `en`.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
/// default) to leave them unversioned.
init(
locale: Locale = .init(identifier: "en"),
assetVersion: String? = nil
) {
self.assetVersion = assetVersion
self.locale = locale
}
// MARK: Computed
var content: some HTML {
p { "Stub content" }
}
var lang: String {
locale.language.languageCode?.identifier ?? "en"
}
var metadata: some HTML {
meta(
.name("stub"),
.content("marker")
)
}
var scripts: [any Asset] {
[StubAsset(
fileExtensions: [.css, .js],
fileName: "stub"
)]
}
var stylesheets: [any Asset] {
[StubAsset(
fileExtensions: [.css, .js],
fileName: "stub"
)]
}
var title: String {
"Stub Page"
}
}