Compare commits
2
Commits
0.5.0
...
ff9f679fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9f679fcb | ||
|
|
a13bc17629 |
+5
-19
@@ -1,13 +1,12 @@
|
|||||||
import { test as base, expect, Page } from "@playwright/test";
|
import { test as base, expect, Page } from "@playwright/test";
|
||||||
import { spawn, ChildProcess } from "child_process";
|
import { spawn, ChildProcess } from "child_process";
|
||||||
import * as fs from "fs";
|
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a fresh Sustenance server against a unique, throwaway database on a
|
* Starts a fresh Sustenance server against an in-memory SQLite database on a
|
||||||
* unique port for each test, and tears it down afterwards. This gives every
|
* unique port for each test, and tears it down afterwards. Every test gets a
|
||||||
* test a clean DB with no shared state between tests.
|
* clean DB with no shared state between tests, and nothing is written to disk.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
||||||
server: [
|
server: [
|
||||||
@@ -15,10 +14,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
|||||||
const server = await startServer();
|
const server = await startServer();
|
||||||
await use({ baseURL: server.baseURL });
|
await use({ baseURL: server.baseURL });
|
||||||
await killTree(server.child);
|
await killTree(server.child);
|
||||||
// Clean up the DB files (including -wal / -shm).
|
|
||||||
for (const suffix of ["", "-wal", "-shm"]) {
|
|
||||||
fs.rmSync(server.dbPath + suffix, { force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ scope: "test", auto: true },
|
{ scope: "test", auto: true },
|
||||||
],
|
],
|
||||||
@@ -35,12 +30,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
|||||||
/** Starts a server, retrying on a fresh port if the first attempt fails to bind. */
|
/** Starts a server, retrying on a fresh port if the first attempt fails to bind. */
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const dbPath = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`sustenance-e2e-${process.pid}-${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2)}.db`,
|
|
||||||
);
|
|
||||||
const port = 20000 + Math.floor(Math.random() * 30000);
|
const port = 20000 + Math.floor(Math.random() * 30000);
|
||||||
const baseURL = `http://localhost:${port}`;
|
const baseURL = `http://localhost:${port}`;
|
||||||
|
|
||||||
@@ -50,7 +39,7 @@ async function startServer() {
|
|||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
DATABASE_PATH: dbPath,
|
DATABASE_IN_MEMORY: "1",
|
||||||
REGISTRATION_MODE: "open",
|
REGISTRATION_MODE: "open",
|
||||||
BIND_ADDRESS: `127.0.0.1:${port}`,
|
BIND_ADDRESS: `127.0.0.1:${port}`,
|
||||||
PUBLIC_BASE_URL: baseURL,
|
PUBLIC_BASE_URL: baseURL,
|
||||||
@@ -72,13 +61,10 @@ async function startServer() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForServer(baseURL, child);
|
await waitForServer(baseURL, child);
|
||||||
return { baseURL, child, dbPath };
|
return { baseURL, child };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The server may have failed to bind (port collision). Clean up and retry.
|
// The server may have failed to bind (port collision). Clean up and retry.
|
||||||
await killTree(child);
|
await killTree(child);
|
||||||
for (const suffix of ["", "-wal", "-shm"]) {
|
|
||||||
fs.rmSync(dbPath + suffix, { force: true });
|
|
||||||
}
|
|
||||||
if (attempt === 4) {
|
if (attempt === 4) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`server failed to start after retries; last stderr:\n${stderr}\n${error}`,
|
`server failed to start after retries; last stderr:\n${stderr}\n${error}`,
|
||||||
|
|||||||
+12
-1
@@ -42,6 +42,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into());
|
let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into());
|
||||||
|
let database_in_memory = env::var("DATABASE_IN_MEMORY")
|
||||||
|
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false);
|
||||||
let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
||||||
// For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers
|
// For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers
|
||||||
// reject IP addresses as RP IDs). Access the app via http://localhost:PORT.
|
// reject IP addresses as RP IDs). Access the app via http://localhost:PORT.
|
||||||
@@ -69,7 +72,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the adapters (ports) and wire them into application services.
|
// Build the adapters (ports) and wire them into application services.
|
||||||
let db = SqliteDatabase::open(&database_path).await?;
|
let db = if database_in_memory {
|
||||||
|
SqliteDatabase::open_in_memory().await?
|
||||||
|
} else {
|
||||||
|
SqliteDatabase::open(&database_path).await?
|
||||||
|
};
|
||||||
let users: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository);
|
let users: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository);
|
||||||
let sessions: Arc<dyn SessionRepository> = Arc::new(SqliteSessionRepository);
|
let sessions: Arc<dyn SessionRepository> = Arc::new(SqliteSessionRepository);
|
||||||
let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository);
|
let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository);
|
||||||
@@ -163,6 +170,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
info!("shutdown complete; closing database");
|
info!("shutdown complete; closing database");
|
||||||
|
// Explicitly close the pool so SQLite can checkpoint and remove the
|
||||||
|
// WAL/SHM sidecar files. Without this, the pool's background close task
|
||||||
|
// races with process exit and the sidecars can be left behind.
|
||||||
|
db.close().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-14
@@ -39,26 +39,32 @@ impl SqliteDatabase {
|
|||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// Opens an in-memory database. Every connection in the pool shares the same
|
||||||
|
/// in-memory database via a named shared-cache database, so the schema and
|
||||||
|
/// data are visible across the whole pool. Nothing is persisted to disk.
|
||||||
pub async fn open_in_memory() -> DomainResult<Self> {
|
pub async fn open_in_memory() -> DomainResult<Self> {
|
||||||
// A pool of `:memory:` connections would each get a separate database,
|
// A plain `:memory:` database is private to each connection, so a pool
|
||||||
// so use a unique temporary file that shares the schema across the pool.
|
// would get a separate database per connection. Use a uniquely-named
|
||||||
|
// shared-cache in-memory database instead so the whole pool shares one.
|
||||||
|
//
|
||||||
|
// The name must be unique per call: shared-cache in-memory databases are
|
||||||
|
// keyed by name, so two calls with the same name within a process would
|
||||||
|
// resolve to the same database and share state. This matters for the
|
||||||
|
// unit tests, which run many `open_in_memory()` calls in parallel within
|
||||||
|
// a single process and need isolation from each other. The counter makes
|
||||||
|
// each call unique. (Shared-cache and in-memory databases are per-process,
|
||||||
|
// so there is no cross-process collision to guard against.)
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
let path = std::env::temp_dir().join(format!(
|
let filename = format!("file:sustenance-in-memory-{}", unique);
|
||||||
"sustenance-test-{}-{}-{}.db",
|
|
||||||
std::process::id(),
|
|
||||||
now(),
|
|
||||||
unique
|
|
||||||
));
|
|
||||||
let path = path.to_str().unwrap();
|
|
||||||
let options = SqliteConnectOptions::new()
|
let options = SqliteConnectOptions::new()
|
||||||
.filename(path)
|
.filename(filename)
|
||||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
.in_memory(true)
|
||||||
|
.shared_cache(true)
|
||||||
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Memory)
|
||||||
.foreign_keys(true)
|
.foreign_keys(true)
|
||||||
.busy_timeout(std::time::Duration::from_secs(5))
|
.busy_timeout(std::time::Duration::from_secs(5));
|
||||||
.create_if_missing(true);
|
|
||||||
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
|
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
|
||||||
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
|
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
|
||||||
seed_default_categories(&pool).await?;
|
seed_default_categories(&pool).await?;
|
||||||
@@ -68,6 +74,14 @@ impl SqliteDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteDatabase {
|
impl SqliteDatabase {
|
||||||
|
/// Closes the connection pool, waiting for all connections to be released
|
||||||
|
/// and closed. This lets SQLite checkpoint and remove the WAL/SHM sidecar
|
||||||
|
/// files on a clean shutdown; without it, the pool's background close task
|
||||||
|
/// races with process exit and the sidecars may be left behind.
|
||||||
|
pub async fn close(&self) {
|
||||||
|
self.pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs `operation` inside a single transaction, committing on success and
|
/// Runs `operation` inside a single transaction, committing on success and
|
||||||
/// rolling back on error. Multiple repositories can participate in the same
|
/// rolling back on error. Multiple repositories can participate in the same
|
||||||
/// transaction so their writes commit together atomically.
|
/// transaction so their writes commit together atomically.
|
||||||
|
|||||||
Reference in New Issue
Block a user