2 Commits
Author SHA1 Message Date
sbstp 61ea97265c add playwright tests 2026-08-01 21:24:48 -04:00
sbstp 250a18cfbb remove dead code 2026-08-01 20:35:14 -04:00
14 changed files with 316 additions and 86 deletions
+3
View File
@@ -3,3 +3,6 @@
/sustenance.db*
/.env
/seed.json
/e2e/node_modules/
/e2e/test-results/
/e2e/playwright-report/
+21
View File
@@ -62,3 +62,24 @@ cargo fmt --all -- --check
cargo check
cargo test
```
### End-to-end tests (Playwright)
The e2e tests live in `e2e/` and use Playwright with a real browser. Each test
starts its own server against a fresh, throwaway database on a unique port, so
tests are fully isolated from each other and from your real `sustenance.db`.
```sh
# one-time setup
cd e2e
npm install
npx playwright install chromium
# run the tests (builds the server automatically via globalSetup)
cd e2e
npx playwright test
```
The Playwright `globalSetup` runs `cargo build` before the suite, and each test
launches its own server against a fresh database, so no manual build or server
start is required.
+98
View File
@@ -0,0 +1,98 @@
import { test as base, expect, Page } from "@playwright/test";
import { spawn, ChildProcess } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
/**
* Starts a fresh Sustenance server against a unique, throwaway database on a
* unique port for each test, and tears it down afterwards. This gives every
* test a clean DB with no shared state between tests.
*/
export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
server: [
async ({}, use) => {
const dbPath = path.join(
os.tmpdir(),
`sustenance-e2e-${process.pid}-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.db`,
);
const port = 3200 + Math.floor(Math.random() * 2000);
const baseURL = `http://127.0.0.1:${port}`;
const child = spawn(
path.resolve(__dirname, "..", "target", "debug", "sustenance"),
[],
{
env: {
...process.env,
DATABASE_PATH: dbPath,
REGISTRATION_MODE: "open",
BIND_ADDRESS: `127.0.0.1:${port}`,
// Point SEED_CONFIG at a nonexistent file so no default user is created.
SEED_CONFIG: path.join(os.tmpdir(), "sustenance-e2e-no-seed.json"),
},
stdio: "ignore",
// Run in its own process group so we can kill the whole tree.
detached: true,
},
);
await waitForServer(baseURL, child);
await use({ baseURL });
await killTree(child);
// Clean up the DB files (including -wal / -shm).
for (const suffix of ["", "-wal", "-shm"]) {
fs.rmSync(dbPath + suffix, { force: true });
}
},
{ scope: "test", auto: true },
],
// Provide a page whose baseURL points at this test's server.
page: async ({ browser, server }, use) => {
const context = await browser.newContext({ baseURL: server.baseURL });
const page = await context.newPage();
await use(page);
await context.close();
},
});
async function waitForServer(baseURL: string, child: ChildProcess) {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`server exited early with code ${child.exitCode}`);
}
try {
const res = await fetch(baseURL + "/login");
if (res.ok) return;
} catch {
// not up yet
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("timed out waiting for server to start");
}
async function killTree(child: ChildProcess) {
try {
process.kill(-child.pid!, "SIGTERM");
} catch {
child.kill("SIGTERM");
}
// Give it a moment to shut down gracefully, then force-kill if needed.
const exited = new Promise((resolve) => child.once("exit", resolve));
const timeout = new Promise((resolve) => setTimeout(resolve, 5000));
await Promise.race([exited, timeout]);
try {
process.kill(-child.pid!, "SIGKILL");
} catch {
/* already gone */
}
}
export { expect };
+13
View File
@@ -0,0 +1,13 @@
import { execSync } from "child_process";
import * as path from "path";
/**
* Builds the Rust server before the test suite runs, so the fixture can launch
* `target/debug/sustenance` without requiring a manual `cargo build`.
*/
export default function globalSetup() {
execSync("cargo build", {
cwd: path.resolve(__dirname, ".."),
stdio: "inherit",
});
}
+20
View File
@@ -0,0 +1,20 @@
import { Page, expect } from "@playwright/test";
/** Registers a fresh account and lands on the lists page. */
export async function registerAndLogin(page: Page, email: string) {
await page.goto("/register");
await page.fill("#display-name", "Test User");
await page.fill("#email", email);
await page.fill("#password", "a-strong-password");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/lists/);
}
/** Creates a meal with the given name and markdown description. */
export async function createMeal(page: Page, name: string, description: string) {
await page.goto("/meals/new");
await page.fill("#meal-name", name);
await page.fill("#meal-description", description);
await page.click('button:has-text("Save meal")');
await expect(page).toHaveURL(/\/meals\/\d+/);
}
+74
View File
@@ -0,0 +1,74 @@
{
"name": "sustenance-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sustenance-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.45.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "sustenance-e2e",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "^1.45.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30_000,
retries: 0,
globalSetup: "./global-setup.ts",
use: {
trace: "on-first-retry",
},
});
+15
View File
@@ -0,0 +1,15 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import { registerAndLogin } from "../helpers";
test("a user can register and log in", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await expect(page.locator("h1")).toContainText("Grocery lists");
});
test("a user can log out", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await page.click('button:has-text("Sign out")');
await expect(page).toHaveURL(/\/login/);
await expect(page.locator("h1")).toContainText("Welcome back");
});
+21
View File
@@ -0,0 +1,21 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import { registerAndLogin } from "../helpers";
test("a user can create a meal", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await page.goto("/meals/new");
await page.fill("#meal-name", "Spaghetti Bolognese");
await page.fill("#meal-description", "## Ingredients\n\nA classic weeknight dinner.");
await page.click('button:has-text("Save meal")');
// Lands on the meal detail page and renders the markdown description.
await expect(page).toHaveURL(/\/meals\/\d+/);
await expect(page.locator("h1")).toContainText("Spaghetti Bolognese");
await expect(page.locator(".markdown h2")).toContainText("Ingredients");
// The meal appears on the meals index.
await page.goto("/meals");
await expect(page.locator(".list-card")).toContainText("Spaghetti Bolognese");
});
+9 -7
View File
@@ -16,11 +16,7 @@ use axum::{
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, de::DeserializeOwned};
use thiserror::Error;
use tower_http::{
services::ServeDir,
set_header::SetResponseHeaderLayer,
trace::TraceLayer,
};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use tracing::{error, warn};
use crate::domain::{DomainError, SessionUser};
@@ -87,8 +83,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/meals/{meal_id}/edit", post(edit_meal))
.route("/meals/{meal_id}/delete", post(delete_meal))
.route("/meals/{meal_id}/ingredients", post(add_ingredient))
.route("/meals/{meal_id}/ingredients/{ingredient_id}/edit", post(edit_ingredient))
.route("/meals/{meal_id}/ingredients/{ingredient_id}/delete", post(delete_ingredient))
.route(
"/meals/{meal_id}/ingredients/{ingredient_id}/edit",
post(edit_ingredient),
)
.route(
"/meals/{meal_id}/ingredients/{ingredient_id}/delete",
post(delete_ingredient),
)
.route("/lists/{list_id}/add-meal", post(add_meal_to_list))
.route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page))
+2 -2
View File
@@ -25,9 +25,9 @@ use crate::ports::{
use crate::security::{Argon2PasswordHasher, RandomTokenGenerator};
use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode};
use crate::sqlite::{
SqliteCategoryRepository, SqliteInvitationRepository, SqliteItemRepository,
SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository,
SqliteListRepository, SqliteMealIngredientRepository, SqliteMealRepository,
SqliteSessionRepository, SqliteDatabase, SqliteUserRepository,
SqliteSessionRepository, SqliteUserRepository,
};
#[tokio::main]
+1 -10
View File
@@ -64,16 +64,7 @@ pub trait ListRepository: Send + Sync {
#[async_trait]
pub trait CategoryRepository: Send + Sync {
async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>>;
async fn create_category(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<i64>;
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>>;
async fn create_category(&self, txn: &mut SqliteConnection, name: String) -> DomainResult<i64>;
}
/// A single item to insert in bulk, without a per-item revision bump.
+16 -67
View File
@@ -7,12 +7,11 @@ use sha2::{Digest, Sha256};
use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions};
use crate::domain::{
Category, DomainError, DomainResult, GroceryList, Item, Meal, MealIngredient, SessionUser,
User,
Category, DomainError, DomainResult, GroceryList, Item, Meal, MealIngredient, SessionUser, User,
};
use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListRepository, MealIngredientRepository,
MealRepository, NewItem, SessionRepository, UserRepository,
CategoryRepository, InvitationRepository, ItemRepository, ListRepository,
MealIngredientRepository, MealRepository, NewItem, SessionRepository, UserRepository,
};
#[derive(Clone)]
@@ -416,11 +415,7 @@ impl CategoryRepository for SqliteCategoryRepository {
.collect())
}
async fn create_category(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<i64> {
async fn create_category(&self, txn: &mut SqliteConnection, name: String) -> DomainResult<i64> {
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM categories")
.fetch_one(&mut *txn)
.await
@@ -447,26 +442,6 @@ impl CategoryRepository for SqliteCategoryRepository {
.get::<i64, _>(0);
Ok(id)
}
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>> {
let row = sqlx::query(
"SELECT id, name
FROM categories
WHERE name = ?1 COLLATE NOCASE",
)
.bind(&name)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| Category {
id: row.get(0),
name: row.get(1),
}))
}
}
#[derive(Clone, Copy)]
@@ -975,10 +950,7 @@ impl MealIngredientRepository for SqliteMealIngredientRepository {
}
}
async fn ensure_category(
txn: &mut SqliteConnection,
category_id: Option<i64>,
) -> DomainResult<()> {
async fn ensure_category(txn: &mut SqliteConnection, category_id: Option<i64>) -> DomainResult<()> {
let Some(category_id) = category_id else {
return Ok(());
};
@@ -1381,11 +1353,7 @@ mod tests {
let id = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.create_category(txn, "Bakery".into())
.await
})
Box::pin(async move { categories.create_category(txn, "Bakery".into()).await })
})
.await
.unwrap();
@@ -1402,34 +1370,12 @@ mod tests {
let result = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.create_category(txn, "Produce".into())
.await
})
Box::pin(async move { categories.create_category(txn, "Produce".into()).await })
})
.await;
assert!(matches!(result, Err(DomainError::Conflict)));
}
#[tokio::test]
async fn category_by_name_resolves_globally() {
let db = setup().await;
let categories = SqliteCategoryRepository;
let found = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.category_by_name(txn, "produce".into())
.await
})
})
.await
.unwrap();
assert_eq!(found.unwrap().name, "Produce");
}
// ---- ItemRepository ----
#[tokio::test]
@@ -1977,7 +1923,14 @@ mod tests {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.add_ingredient(txn, meal.id, "X".into(), String::new(), String::new(), Some(9999))
.add_ingredient(
txn,
meal.id,
"X".into(),
String::new(),
String::new(),
Some(9999),
)
.await
})
})
@@ -2054,11 +2007,7 @@ mod tests {
let result = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.delete_ingredient(txn, meal.id, 9999)
.await
})
Box::pin(async move { ingredients.delete_ingredient(txn, meal.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));