Project updates from Template
This commit contains the latest updates from the generic Website template, which move the toolchain to Swift 6.4, gate analytics and structured data behind traits, and rework page routing: - Updated the Swift toolchain for all the package dependencies and the Website service to version 6.4. (345b305) - Adapted all the existing middlewares in the Infrastructure package to Swift 6.4. (26008df) - Moved test resource teardown in the Persistence and the Website library into defer blocks. (27984a9) - Gated analytics behind a trait and reworked structured data in the Infrastructure package. (c7d4769) - Enabled the Analytics trait in the Website service. (f6a6bbd) - Reworked page routing in the Website service. (d1cb2c1) - Gated the structured data features in the Infrastructure package behind a trait. (f0a8791) - Enabled the StructuredData trait of the Infrastructure package in the Website service. (bbfdeb3) - Fixed the asset pin and a build warning in the Dockerfile of the Website service. (6ed0f9b) The root README change in 345b305 was left out, as this project no longer carries that file. From d1cb2c1, the RootController rework was left out, as the prototype serves a session-aware landing page and routes its other pages through their own controllers; the view-event reporter was appended to the prototype's shared script instead. The prototype code was adapted to the stricter Swift 6.4 settings: `String(format:)` was swapped for safe formatting, the NoIndex middleware takes a `@concurrent` next handler, and the tests flagged by warnings-as-errors were tightened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
326299fc35
commit
911424de24
57 files changed
+864
-273
No files matched your search
+12
-12
@@ -4,12 +4,11 @@
|
||||
FROM node:22-alpine AS assets
|
||||
|
||||
ARG ESBUILD_VERSION=0.28.1
|
||||
ARG OXIPNG_VERSION=9.1.5
|
||||
ARG OXIPNG_VERSION=9.1
|
||||
ARG SVGO_VERSION=4.0.2
|
||||
|
||||
# Install the minifiers in their own layer, so they are cached across asset changes.
|
||||
# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do not break the build when the base
|
||||
# image advances.
|
||||
# The oxipng pin stops at major.minor: Alpine drops a package's old builds, so a patch pin fails on its next release.
|
||||
RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
|
||||
&& npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
|
||||
|
||||
@@ -30,7 +29,7 @@ COPY --from=assets /static /
|
||||
# ================================
|
||||
# Build image
|
||||
# ================================
|
||||
FROM swift:6.3-noble AS build
|
||||
FROM swift:6.4-noble AS build
|
||||
|
||||
# Install OS updates
|
||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
@@ -66,12 +65,13 @@ COPY ./Services/Website/Sources ./Services/Website/Sources
|
||||
|
||||
# Build the application, with optimizations, with static linking, and using jemalloc.
|
||||
#
|
||||
# SPM validates the path of every target in the root package — including the test targets — even when only the
|
||||
# executable product is built, so those directories have to exist. They are created empty instead of copied: the test
|
||||
# sources are not needed to compile the product, keeping them out means editing a test never invalidates this layer,
|
||||
# and it keeps the local database bind mount under Tests/DB out of the image entirely.
|
||||
RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
||||
&& swift build --package-path ./Services/Website -c release \
|
||||
# SPM validates every target path, test targets and their resources included, so those directories must exist. They are
|
||||
# created empty, not copied, so an edited test does not invalidate this layer and the Tests/DB bind mount stays out.
|
||||
#
|
||||
# The native build system works around https://github.com/swiftlang/swift-package-manager/issues/10592: Swift 6.4's
|
||||
# default one fails to link a static Foundation.
|
||||
RUN mkdir -p ./Services/Website/Tests/App/Static ./Services/Website/Tests/Library \
|
||||
&& swift build --package-path ./Services/Website -c release --build-system native \
|
||||
--product "Website" \
|
||||
--static-swift-stdlib \
|
||||
-Xlinker -ljemalloc
|
||||
@@ -80,14 +80,14 @@ RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
||||
WORKDIR /staging
|
||||
|
||||
# Copy main executable to staging area, without its debug sections
|
||||
RUN cp "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/Website" ./ \
|
||||
RUN cp "$(swift build --package-path /build/Services/Website -c release --build-system native --show-bin-path)/Website" ./ \
|
||||
&& strip --strip-debug ./Website
|
||||
|
||||
# Copy static swift backtracer binary to staging area
|
||||
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
||||
|
||||
# Copy resources bundled by SPM to staging area
|
||||
RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
||||
RUN find -L "$(swift build --package-path /build/Services/Website -c release --build-system native --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
||||
|
||||
# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage
|
||||
RUN mkdir -p ./Resources/Static
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version: 6.3
|
||||
// swift-tools-version: 6.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -21,7 +21,11 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(
|
||||
path: "../../Packages/Infrastructure"
|
||||
path: "../../Packages/Infrastructure",
|
||||
traits: [
|
||||
"Analytics",
|
||||
"StructuredData",
|
||||
]
|
||||
),
|
||||
.package(
|
||||
path: "../../Packages/Localization"
|
||||
@@ -166,3 +170,15 @@ let package = Package(
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
|
||||
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
|
||||
for target in package.targets {
|
||||
target.swiftSettings = (target.swiftSettings ?? []) + [
|
||||
.strictMemorySafety(),
|
||||
.treatAllWarnings(as: .error),
|
||||
.treatWarning("DeprecatedDeclaration", as: .warning),
|
||||
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
|
||||
.enableUpcomingFeature("InferIsolatedConformances"),
|
||||
]
|
||||
}
|
||||
@@ -21,7 +21,7 @@ The service:
|
||||
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
|
||||
- Docker (optional) for the containerized run/deploy workflow.
|
||||
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
|
||||
|
||||
@@ -139,7 +139,7 @@ Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL
|
||||
| `summary` | `<meta name="description">` | The page's one-line description. |
|
||||
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
|
||||
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
|
||||
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
|
||||
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. Requires Infrastructure's `StructuredData` trait, which `Package.swift` enables. |
|
||||
|
||||
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
|
||||
|
||||
@@ -394,7 +394,7 @@ The test sources are deliberately absent from the image. SPM validates the path
|
||||
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
|
||||
|
||||
```sh
|
||||
docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}'
|
||||
docker buildx imagetools inspect swift:6.4-noble --format '{{.Manifest.Digest}}'
|
||||
```
|
||||
|
||||
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
|
||||
|
||||
@@ -40,3 +40,32 @@ if (listingForm) {
|
||||
listingForm.addEventListener("change", apply);
|
||||
apply();
|
||||
}
|
||||
|
||||
// Elements tagged `data-view-event` are reported the first time they are fully on screen, when the tracker is present.
|
||||
(() => {
|
||||
const elements = document.querySelectorAll("[data-view-event]");
|
||||
|
||||
if (!window.umami || elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = "data-view-event-";
|
||||
|
||||
// Each `data-view-event-<name>` attribute is a property, as `data-umami-event-<name>` is for a click.
|
||||
const properties = (element) => Object.fromEntries(
|
||||
[...element.attributes]
|
||||
.filter((attribute) => attribute.name.startsWith(prefix))
|
||||
.map((attribute) => [attribute.name.slice(prefix.length), attribute.value])
|
||||
);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
observer.unobserve(entry.target);
|
||||
window.umami.track(entry.target.getAttribute("data-view-event"), properties(entry.target));
|
||||
}
|
||||
}
|
||||
}, { threshold: 1 });
|
||||
|
||||
elements.forEach((element) => observer.observe(element));
|
||||
})();
|
||||
@@ -1,14 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
extension Int {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The amount of euro cents as a price: whole euros bare (`€240`), anything else to the cent (`€12.50`).
|
||||
var euros: String {
|
||||
isMultiple(of: 100)
|
||||
let cents = self % 100
|
||||
|
||||
return isMultiple(of: 100)
|
||||
? "€\(self / 100)"
|
||||
: String(format: "€%d.%02d", self / 100, self % 100)
|
||||
: "€\(self / 100).\(cents < 10 ? "0" : "")\(cents)"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -72,7 +72,7 @@ extension Shop {
|
||||
|
||||
/// The shop's standing in a line: where it is, since when, and how it is rated.
|
||||
var standing: String {
|
||||
"\(location) · selling on CCN since \(sinceYear) · \(String(format: "%.1f", rating)) from \(ratedSales) rated sales"
|
||||
"\(location) · selling on CCN since \(sinceYear) · \(rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))) from \(ratedSales) rated sales"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -83,7 +83,7 @@ extension Double {
|
||||
|
||||
/// The grade to one decimal place, e.g. `9.8`.
|
||||
var gradeLabel: String {
|
||||
String(format: "%.1f", self)
|
||||
formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,6 +46,8 @@ extension Page {
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The `hreflang` alternates tying the page's language editions together, or nothing without an origin — the annotations require absolute URLs.
|
||||
///
|
||||
/// Nothing is emitted for a single-language site either: a set naming one edition tells a search engine nothing it cannot already see.
|
||||
@@ -91,6 +93,17 @@ extension Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// The locales of the page's other language editions, in ``ogLocale``'s form.
|
||||
/// - Parameter languages: the languages the page is published in; every catalog language by default.
|
||||
/// - Returns: one locale per other language; empty for a single-language page.
|
||||
func ogAlternateLocales(
|
||||
languages: [Language] = Language.all
|
||||
) -> [String] {
|
||||
languages
|
||||
.filter { $0.identifier != lang }
|
||||
.map { Self.ogLocales[$0.identifier] ?? $0.identifier }
|
||||
}
|
||||
|
||||
/// The ``preloadedFonts`` links, then the icon, manifest, and theme colour metadata shared by every page of the website.
|
||||
///
|
||||
/// A preload URL must match the stylesheet's `@font-face` source exactly — unversioned, with `crossorigin` — or the browser fetches the
|
||||
|
||||
@@ -179,6 +179,7 @@ private extension ComparePage {
|
||||
of copy: Listing
|
||||
) -> [(term: String, value: String)] {
|
||||
let customs = copy.shop.shipsFromEU ? "" : " · customs may apply"
|
||||
let rating = copy.shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))
|
||||
|
||||
return [
|
||||
("Asking", copy.priceCents.euros + (copy.acceptsOffers ? " · accepts offers" : "")),
|
||||
@@ -187,7 +188,7 @@ private extension ComparePage {
|
||||
("Declared defects", copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")),
|
||||
("Ship to NL", "\(copy.shippingCents.euros) tracked · \(copy.shop.handlingDays)-day handling\(customs)"),
|
||||
("Shop", "\(copy.shop.name) · \(copy.shop.location)"),
|
||||
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(String(format: "%.1f", copy.shop.rating)) · \(copy.shop.returnsNote)"),
|
||||
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(rating) · \(copy.shop.returnsNote)"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ extension SellerDashboardPage: Page {
|
||||
tile("\(offers.count)", "Open offers", offers.first.map { "next: \($0.expiresAt.countdown(from: now))" })
|
||||
tile("\(viewer?.unreadMessages ?? 0)", "Unread conversations", nil)
|
||||
tile("\(sales.count)", "Sales", "\(sales.count { $0.next == .sent }) awaiting your dispatch")
|
||||
tile(String(format: "%.1f", shop.rating), "Rating", "\(shop.ratedSales) rated sales")
|
||||
tile(shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))), "Rating", "\(shop.ratedSales) rated sales")
|
||||
}
|
||||
section(.class("card sell__panel")) {
|
||||
div(.class("section-head")) {
|
||||
|
||||
@@ -22,7 +22,7 @@ public struct NoIndexMiddleware<Context: RequestContext>: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
do {
|
||||
var response = try await next(request, context)
|
||||
|
||||
@@ -26,6 +26,17 @@ struct PageDefaultsTests {
|
||||
|
||||
// MARK: Method tests
|
||||
|
||||
@Test
|
||||
func `lists no alternate social card locales for a single language`() {
|
||||
#expect(StubPage().ogAlternateLocales(languages: [.default]).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lists the social card locale of every other language`() {
|
||||
#expect(StubPage().ogAlternateLocales(languages: Self.languages) == ["nl"])
|
||||
#expect(StubPage(locale: .init(identifier: "nl")).ogAlternateLocales(languages: Self.languages) == ["en_US"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no language alternates without an origin`() {
|
||||
let html = StubPage().languageAlternates(
|
||||
|
||||
@@ -119,24 +119,22 @@ struct CollectorPagesTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `series lists an issue's copies cheapest delivered first, ready to compare`() {
|
||||
func `series lists an issue's copies cheapest delivered first, ready to compare`() throws {
|
||||
let html = SeriesPage(
|
||||
series: Self.nightward,
|
||||
listings: [.stub(), .stub(slug: "cheap", priceCents: 9_000, defects: ["Foxing / staining"])],
|
||||
issue: 1,
|
||||
viewer: .init(displayName: "Mara")
|
||||
).render()
|
||||
let cheap = try? #require(html.range(of: #"value="cheap""#))
|
||||
let dear = try? #require(html.range(of: #"value="nightward-1""#))
|
||||
let cheap = try #require(html.range(of: #"value="cheap""#))
|
||||
let dear = try #require(html.range(of: #"value="nightward-1""#))
|
||||
|
||||
#expect(html.contains("Copies of #1 — 2 listed"))
|
||||
#expect(html.contains(#"action="/compare" method="get""#))
|
||||
#expect(html.contains("Foxing / staining"))
|
||||
#expect(html.contains(#"href="/wants/new?title=Nightward%20%232">None listed · alert me"#))
|
||||
|
||||
if let cheap, let dear {
|
||||
#expect(cheap.lowerBound < dear.lowerBound)
|
||||
}
|
||||
#expect(cheap.lowerBound < dear.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -153,23 +151,21 @@ struct CollectorPagesTests {
|
||||
// MARK: ComparePage tests
|
||||
|
||||
@Test
|
||||
func `comparison orders copies by what they cost delivered, and flags a customs border`() {
|
||||
func `comparison orders copies by what they cost delivered, and flags a customs border`() throws {
|
||||
let abroad = Shop(
|
||||
slug: "longbox", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, isVerified: false,
|
||||
rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, shipsNote: "", returnsNote: "returns by agreement", about: ""
|
||||
)
|
||||
let html = ComparePage(copies: [.stub(), .stub(slug: "abroad", shop: abroad, priceCents: 15_000)]).render()
|
||||
let first = try? #require(html.range(of: "€156"))
|
||||
let second = try? #require(html.range(of: "€246"))
|
||||
let first = try #require(html.range(of: "€156"))
|
||||
let second = try #require(html.range(of: "€246"))
|
||||
|
||||
#expect(html.contains("customs may apply"))
|
||||
#expect(html.components(separatedBy: "customs may apply").count - 1 == 1)
|
||||
#expect(html.contains(#"href="/sign-in?return=/listing/abroad">Buy now"#))
|
||||
#expect(html.contains("58 · 4.6 · returns by agreement"))
|
||||
|
||||
if let first, let second {
|
||||
#expect(first.lowerBound < second.lowerBound)
|
||||
}
|
||||
#expect(first.lowerBound < second.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+32
-40
@@ -43,29 +43,25 @@ struct HealthControllerTests {
|
||||
)
|
||||
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"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
defer {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
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"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,29 +85,25 @@ struct HealthControllerTests {
|
||||
)
|
||||
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"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
defer {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
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"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -41,8 +41,8 @@ struct PhotoStoreTests {
|
||||
])
|
||||
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
|
||||
file: ByteBuffer
|
||||
) throws {
|
||||
try Self.withFolder { folder in
|
||||
) {
|
||||
Self.withFolder { folder in
|
||||
#expect(throws: PhotoStore.Refusal.notAnImage) {
|
||||
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
|
||||
}
|
||||
@@ -51,8 +51,8 @@ struct PhotoStoreTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `turns down too many photos, and one too large`() throws {
|
||||
try Self.withFolder { folder in
|
||||
func `turns down too many photos, and one too large`() {
|
||||
Self.withFolder { folder in
|
||||
let store = PhotoStore(staticFilesPath: folder)
|
||||
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
|
||||
|
||||
|
||||
Reference in new issue
Block a user