feat(backup): expose backup/restore via authenticated API endpoints
- Add GET /api/v1/backup and POST /api/v1/backup/restore endpoints behind AuthenticatedUser - Add BackupService to AppState and BackupClient for HTTP access - Update CLI backup/restore to use API instead of direct DB access - Remove --database-url flag from backup and restore commands - Increase body limit to 50MB for restore endpoint - Add API and CLI tests for auth, export, restore, and round-trip - Update README to document auth requirement and API endpoints
This commit is contained in:
parent
e31f1c741e
commit
50a372015c
12 changed files with 304 additions and 30 deletions
11
README.md
11
README.md
|
|
@ -130,8 +130,8 @@ brewlog brew <cmd> Manage brews (add, list, get, delete — no update)
|
||||||
brewlog cafe <cmd> Manage cafes
|
brewlog cafe <cmd> Manage cafes
|
||||||
brewlog cup <cmd> Manage cups (cafe visits with ratings)
|
brewlog cup <cmd> Manage cups (cafe visits with ratings)
|
||||||
brewlog token <cmd> Manage API tokens (create, list, revoke)
|
brewlog token <cmd> Manage API tokens (create, list, revoke)
|
||||||
brewlog backup Export all data to JSON on stdout
|
brewlog backup Export all data to JSON on stdout (requires BREWLOG_TOKEN)
|
||||||
brewlog restore --file F Restore data from a JSON backup into an empty database
|
brewlog restore --file F Restore data from a JSON backup into an empty database (requires BREWLOG_TOKEN)
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `brewlog <command> --help` for detailed options on any command.
|
Use `brewlog <command> --help` for detailed options on any command.
|
||||||
|
|
@ -197,6 +197,8 @@ Migrations run automatically on server startup.
|
||||||
|
|
||||||
### Backup & Restore
|
### Backup & Restore
|
||||||
|
|
||||||
|
Backup and restore go through the API and require authentication (`BREWLOG_TOKEN`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Export all data to JSON
|
# Export all data to JSON
|
||||||
brewlog backup > backup.json
|
brewlog backup > backup.json
|
||||||
|
|
@ -205,7 +207,10 @@ brewlog backup > backup.json
|
||||||
brewlog restore --file backup.json
|
brewlog restore --file backup.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands accept `--database-url` (or `BREWLOG_DATABASE_URL`) to target a specific database.
|
The API endpoints are also available directly:
|
||||||
|
|
||||||
|
- `GET /api/v1/backup` — export all data as JSON (requires auth)
|
||||||
|
- `POST /api/v1/backup/restore` — restore from a JSON backup (requires auth, database must be empty)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
|
||||||
39
src/application/routes/backup.rs
Normal file
39
src/application/routes/backup.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
use crate::application::auth::AuthenticatedUser;
|
||||||
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::server::AppState;
|
||||||
|
use crate::infrastructure::backup::BackupData;
|
||||||
|
|
||||||
|
/// GET /api/v1/backup — export all data as JSON (requires authentication)
|
||||||
|
pub(crate) async fn export_backup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<BackupData>, ApiError> {
|
||||||
|
let data = state
|
||||||
|
.backup_service
|
||||||
|
.export()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::unexpected(e.to_string()))?;
|
||||||
|
Ok(Json(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/backup/restore — restore from JSON backup (requires authentication)
|
||||||
|
pub(crate) async fn restore_backup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<BackupData>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
state.backup_service.restore(payload).await.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("not empty") {
|
||||||
|
ApiError::from(AppError::Conflict(msg))
|
||||||
|
} else {
|
||||||
|
ApiError::from(AppError::unexpected(msg))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(StatusCode::NO_CONTENT.into_response())
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod backup;
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
pub mod cafes;
|
pub mod cafes;
|
||||||
|
|
@ -17,6 +18,7 @@ pub mod tokens;
|
||||||
pub(crate) use auth::is_authenticated;
|
pub(crate) use auth::is_authenticated;
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{Html, IntoResponse, Redirect};
|
use axum::response::{Html, IntoResponse, Redirect};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
|
|
@ -94,7 +96,12 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
"/tokens",
|
"/tokens",
|
||||||
post(tokens::create_token).get(tokens::list_tokens),
|
post(tokens::create_token).get(tokens::list_tokens),
|
||||||
)
|
)
|
||||||
.route("/tokens/:id/revoke", post(tokens::revoke_token));
|
.route("/tokens/:id/revoke", post(tokens::revoke_token))
|
||||||
|
.route("/backup", get(backup::export_backup))
|
||||||
|
.route(
|
||||||
|
"/backup/restore",
|
||||||
|
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
|
||||||
|
);
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/", get(home::home_page))
|
.route("/", get(home::home_page))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use crate::domain::repositories::{
|
||||||
};
|
};
|
||||||
use crate::domain::users::NewUser;
|
use crate::domain::users::NewUser;
|
||||||
use crate::infrastructure::auth::hash_password;
|
use crate::infrastructure::auth::hash_password;
|
||||||
|
use crate::infrastructure::backup::BackupService;
|
||||||
use crate::infrastructure::database::Database;
|
use crate::infrastructure::database::Database;
|
||||||
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
||||||
use crate::infrastructure::repositories::brews::SqlBrewRepository;
|
use crate::infrastructure::repositories::brews::SqlBrewRepository;
|
||||||
|
|
@ -55,6 +56,7 @@ pub struct AppState {
|
||||||
pub foursquare_api_key: String,
|
pub foursquare_api_key: String,
|
||||||
pub openrouter_api_key: String,
|
pub openrouter_api_key: String,
|
||||||
pub openrouter_model: String,
|
pub openrouter_model: String,
|
||||||
|
pub backup_service: Arc<BackupService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -76,6 +78,7 @@ impl AppState {
|
||||||
foursquare_api_key: String,
|
foursquare_api_key: String,
|
||||||
openrouter_api_key: String,
|
openrouter_api_key: String,
|
||||||
openrouter_model: String,
|
openrouter_model: String,
|
||||||
|
backup_service: Arc<BackupService>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
roaster_repo,
|
roaster_repo,
|
||||||
|
|
@ -94,6 +97,7 @@ impl AppState {
|
||||||
foursquare_api_key,
|
foursquare_api_key,
|
||||||
openrouter_api_key,
|
openrouter_api_key,
|
||||||
openrouter_model,
|
openrouter_model,
|
||||||
|
backup_service,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +123,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
let session_repo: Arc<dyn SessionRepository> =
|
let session_repo: Arc<dyn SessionRepository> =
|
||||||
Arc::new(SqlSessionRepository::new(database.clone_pool()));
|
Arc::new(SqlSessionRepository::new(database.clone_pool()));
|
||||||
|
|
||||||
|
let backup_service = Arc::new(BackupService::new(database.clone_pool()));
|
||||||
|
|
||||||
// Bootstrap admin user if no users exist
|
// Bootstrap admin user if no users exist
|
||||||
bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?;
|
bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?;
|
||||||
|
|
||||||
|
|
@ -139,6 +145,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
config.foursquare_api_key,
|
config.foursquare_api_key,
|
||||||
config.openrouter_api_key,
|
config.openrouter_api_key,
|
||||||
config.openrouter_model,
|
config.openrouter_model,
|
||||||
|
backup_service,
|
||||||
);
|
);
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_address)
|
let listener = TcpListener::bind(config.bind_address)
|
||||||
|
|
|
||||||
44
src/infrastructure/client/backup.rs
Normal file
44
src/infrastructure/client/backup.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::infrastructure::backup::BackupData;
|
||||||
|
|
||||||
|
use super::BrewlogClient;
|
||||||
|
|
||||||
|
pub struct BackupClient<'a> {
|
||||||
|
inner: &'a BrewlogClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BackupClient<'a> {
|
||||||
|
pub(crate) fn new(inner: &'a BrewlogClient) -> Self {
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export(&self) -> Result<BackupData> {
|
||||||
|
let url = self.inner.endpoint("api/v1/backup")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::GET, url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue backup export request")?;
|
||||||
|
|
||||||
|
self.inner.handle_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore(&self, data: &BackupData) -> Result<()> {
|
||||||
|
let url = self.inner.endpoint("api/v1/backup/restore")?;
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.request(reqwest::Method::POST, url)
|
||||||
|
.json(data)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to issue backup restore request")?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
StatusCode::NO_CONTENT => Ok(()),
|
||||||
|
_ => Err(self.inner.response_error(response).await),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod backup;
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
pub mod cafes;
|
pub mod cafes;
|
||||||
|
|
@ -44,6 +45,10 @@ impl BrewlogClient {
|
||||||
Self::new(url)
|
Self::new(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn backup(&self) -> backup::BackupClient<'_> {
|
||||||
|
backup::BackupClient::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn roasters(&self) -> roasters::RoastersClient<'_> {
|
pub fn roasters(&self) -> roasters::RoastersClient<'_> {
|
||||||
roasters::RoastersClient::new(self)
|
roasters::RoastersClient::new(self)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/main.rs
15
src/main.rs
|
|
@ -1,8 +1,7 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use brewlog::application::{ServerConfig, serve};
|
use brewlog::application::{ServerConfig, serve};
|
||||||
use brewlog::infrastructure::backup::{BackupData, BackupService};
|
use brewlog::infrastructure::backup::BackupData;
|
||||||
use brewlog::infrastructure::client::BrewlogClient;
|
use brewlog::infrastructure::client::BrewlogClient;
|
||||||
use brewlog::infrastructure::database::Database;
|
|
||||||
use brewlog::presentation::cli::{
|
use brewlog::presentation::cli::{
|
||||||
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens,
|
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens,
|
||||||
};
|
};
|
||||||
|
|
@ -58,10 +57,9 @@ async fn main() -> Result<()> {
|
||||||
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||||
tokens::run(&client, command).await
|
tokens::run(&client, command).await
|
||||||
}
|
}
|
||||||
Commands::Backup(cmd) => {
|
Commands::Backup(_cmd) => {
|
||||||
let database = Database::connect(&cmd.database_url).await?;
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||||
let service = BackupService::new(database.clone_pool());
|
let data = client.backup().export().await?;
|
||||||
let data = service.export().await?;
|
|
||||||
let json = serde_json::to_string_pretty(&data)?;
|
let json = serde_json::to_string_pretty(&data)?;
|
||||||
println!("{json}");
|
println!("{json}");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -69,9 +67,8 @@ async fn main() -> Result<()> {
|
||||||
Commands::Restore(cmd) => {
|
Commands::Restore(cmd) => {
|
||||||
let contents = std::fs::read_to_string(&cmd.file)?;
|
let contents = std::fs::read_to_string(&cmd.file)?;
|
||||||
let data: BackupData = serde_json::from_str(&contents)?;
|
let data: BackupData = serde_json::from_str(&contents)?;
|
||||||
let database = Database::connect(&cmd.database_url).await?;
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||||
let service = BackupService::new(database.clone_pool());
|
client.backup().restore(&data).await?;
|
||||||
service.restore(data).await?;
|
|
||||||
eprintln!("Restore complete.");
|
eprintln!("Restore complete.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,10 @@
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct BackupCommand {
|
pub struct BackupCommand;
|
||||||
/// Database URL to back up from
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
env = "BREWLOG_DATABASE_URL",
|
|
||||||
default_value = "sqlite://brewlog.db"
|
|
||||||
)]
|
|
||||||
pub database_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct RestoreCommand {
|
pub struct RestoreCommand {
|
||||||
/// Database URL to restore into (must be an empty database)
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
env = "BREWLOG_DATABASE_URL",
|
|
||||||
default_value = "sqlite://brewlog.db"
|
|
||||||
)]
|
|
||||||
pub database_url: String,
|
|
||||||
|
|
||||||
/// Path to the backup JSON file
|
/// Path to the backup JSON file
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub file: String,
|
pub file: String,
|
||||||
|
|
|
||||||
37
tests/cli/backup_cli.rs
Normal file
37
tests/cli/backup_cli.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use super::helpers::{create_token, run_brewlog};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_produces_valid_json() {
|
||||||
|
let token = create_token("backup-test");
|
||||||
|
|
||||||
|
let output = run_brewlog(&["backup"], &[("BREWLOG_TOKEN", &token)]);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"backup command failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let data: serde_json::Value =
|
||||||
|
serde_json::from_str(&stdout).expect("backup output is not valid JSON");
|
||||||
|
|
||||||
|
assert_eq!(data["version"], 1);
|
||||||
|
assert!(data["roasters"].is_array());
|
||||||
|
assert!(data["roasts"].is_array());
|
||||||
|
assert!(data["bags"].is_array());
|
||||||
|
assert!(data["gear"].is_array());
|
||||||
|
assert!(data["brews"].is_array());
|
||||||
|
assert!(data["cafes"].is_array());
|
||||||
|
assert!(data["timeline_events"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_requires_auth() {
|
||||||
|
let output = run_brewlog(&["backup"], &[]);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"backup command should fail without auth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod backup_cli;
|
||||||
pub mod bags_cli;
|
pub mod bags_cli;
|
||||||
pub mod brews_cli;
|
pub mod brews_cli;
|
||||||
pub mod cafes_cli;
|
pub mod cafes_cli;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||||
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
|
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||||
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
||||||
|
|
||||||
|
use super::helpers::{create_default_roaster, spawn_app, spawn_app_with_auth};
|
||||||
|
|
||||||
struct TestDb {
|
struct TestDb {
|
||||||
roaster_repo: Arc<dyn RoasterRepository>,
|
roaster_repo: Arc<dyn RoasterRepository>,
|
||||||
roast_repo: Arc<dyn RoastRepository>,
|
roast_repo: Arc<dyn RoastRepository>,
|
||||||
|
|
@ -454,3 +456,145 @@ async fn backup_empty_database() {
|
||||||
let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize");
|
let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize");
|
||||||
assert_eq!(parsed.version, 1);
|
assert_eq!(parsed.version, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- API-level tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backup_export_requires_auth() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.api_url("/backup"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backup_export_returns_data() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Create some data first
|
||||||
|
create_default_roaster(&app).await;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.api_url("/backup"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||||
|
|
||||||
|
let data: BackupData = response.json().await.expect("failed to parse backup data");
|
||||||
|
assert_eq!(data.version, 1);
|
||||||
|
assert_eq!(data.roasters.len(), 1);
|
||||||
|
assert_eq!(data.roasters[0].name, "Test Roasters");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backup_restore_requires_auth() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let backup_data = BackupData {
|
||||||
|
version: 1,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
roasters: vec![],
|
||||||
|
gear: vec![],
|
||||||
|
roasts: vec![],
|
||||||
|
bags: vec![],
|
||||||
|
brews: vec![],
|
||||||
|
cafes: vec![],
|
||||||
|
timeline_events: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url("/backup/restore"))
|
||||||
|
.json(&backup_data)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backup_restore_non_empty_db_returns_conflict() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Create data to make the database non-empty
|
||||||
|
create_default_roaster(&app).await;
|
||||||
|
|
||||||
|
let backup_data = BackupData {
|
||||||
|
version: 1,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
roasters: vec![],
|
||||||
|
gear: vec![],
|
||||||
|
roasts: vec![],
|
||||||
|
bags: vec![],
|
||||||
|
brews: vec![],
|
||||||
|
cafes: vec![],
|
||||||
|
timeline_events: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url("/backup/restore"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.json(&backup_data)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backup_round_trip_via_api() {
|
||||||
|
// 1. Create source app with data
|
||||||
|
let source = spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
create_default_roaster(&source).await;
|
||||||
|
|
||||||
|
// 2. Export via API
|
||||||
|
let response = client
|
||||||
|
.get(source.api_url("/backup"))
|
||||||
|
.bearer_auth(source.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to export backup");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||||
|
let backup_data: BackupData = response.json().await.expect("failed to parse backup");
|
||||||
|
assert_eq!(backup_data.roasters.len(), 1);
|
||||||
|
|
||||||
|
// 3. Restore into a fresh app
|
||||||
|
let target = spawn_app_with_auth().await;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(target.api_url("/backup/restore"))
|
||||||
|
.bearer_auth(target.auth_token.as_ref().unwrap())
|
||||||
|
.json(&backup_data)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to restore backup");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// 4. Verify data was restored by listing roasters
|
||||||
|
let response = client
|
||||||
|
.get(target.api_url("/roasters"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to list roasters");
|
||||||
|
|
||||||
|
let roasters: Vec<Roaster> = response.json().await.expect("failed to parse roasters");
|
||||||
|
assert_eq!(roasters.len(), 1);
|
||||||
|
assert_eq!(roasters[0].name, "Test Roasters");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use brewlog::domain::repositories::{
|
||||||
use brewlog::domain::roasters::{NewRoaster, Roaster};
|
use brewlog::domain::roasters::{NewRoaster, Roaster};
|
||||||
use brewlog::domain::users::NewUser;
|
use brewlog::domain::users::NewUser;
|
||||||
use brewlog::infrastructure::auth::hash_password;
|
use brewlog::infrastructure::auth::hash_password;
|
||||||
|
use brewlog::infrastructure::backup::BackupService;
|
||||||
use brewlog::infrastructure::database::Database;
|
use brewlog::infrastructure::database::Database;
|
||||||
use brewlog::infrastructure::repositories::bags::SqlBagRepository;
|
use brewlog::infrastructure::repositories::bags::SqlBagRepository;
|
||||||
use brewlog::infrastructure::repositories::brews::SqlBrewRepository;
|
use brewlog::infrastructure::repositories::brews::SqlBrewRepository;
|
||||||
|
|
@ -122,6 +123,8 @@ async fn spawn_app_inner(
|
||||||
foursquare_api_key: String,
|
foursquare_api_key: String,
|
||||||
mock_server: Option<wiremock::MockServer>,
|
mock_server: Option<wiremock::MockServer>,
|
||||||
) -> TestApp {
|
) -> TestApp {
|
||||||
|
let backup_service = Arc::new(BackupService::new(_database.clone_pool()));
|
||||||
|
|
||||||
// Create application state
|
// Create application state
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
roaster_repo.clone(),
|
roaster_repo.clone(),
|
||||||
|
|
@ -140,6 +143,7 @@ async fn spawn_app_inner(
|
||||||
foursquare_api_key,
|
foursquare_api_key,
|
||||||
String::new(),
|
String::new(),
|
||||||
"openrouter/free".to_string(),
|
"openrouter/free".to_string(),
|
||||||
|
backup_service,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue