Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
200 lines
7.7 KiB
Rust
200 lines
7.7 KiB
Rust
//! The app's sync client against the real server over HTTP: the wire protocol
|
|
//! end to end, with two devices that each keep a SQLite working copy.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce};
|
|
use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED};
|
|
use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry};
|
|
use mtgc_server::auth::local::hash_password;
|
|
use mtgc_server::{db, router, AppState, Config};
|
|
|
|
/// Start a server on a free port and return its URL. It runs on its own thread
|
|
/// for the rest of the test process.
|
|
fn spawn_server() -> (String, AppState) {
|
|
let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let url = format!("http://{}", std_listener.local_addr().unwrap());
|
|
let public = url.clone();
|
|
let config = Config::from_lookup(move |k| (k == "MTGC_PUBLIC_URL").then(|| public.clone())).unwrap();
|
|
let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap();
|
|
let app = router(state.clone());
|
|
std_listener.set_nonblocking(true).unwrap();
|
|
std::thread::spawn(move || {
|
|
tokio::runtime::Runtime::new().unwrap().block_on(async move {
|
|
let listener = tokio::net::TcpListener::from_std(std_listener).unwrap();
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
});
|
|
(url, state)
|
|
}
|
|
|
|
/// Sign in the way the app does, playing the browser's part by hand.
|
|
fn sign_in(server: &str, user: &str, password: &str) -> mtgc_core::sync::account::SignedIn {
|
|
let pkce = Pkce::new();
|
|
let start = start_url(server, "http://127.0.0.1:9/callback", &pkce, "st", "Test device");
|
|
|
|
let page = ureq::get(&start).call().unwrap();
|
|
let cookie = page.header("set-cookie").unwrap().split(';').next().unwrap().to_string();
|
|
let pending = cookie.strip_prefix("mtgc_login=").unwrap().to_string();
|
|
|
|
let done = ureq::post(&format!("{server}/auth/password"))
|
|
.set("Cookie", &cookie)
|
|
.send_form(&[("pending", &pending), ("username", user), ("password", password)])
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
let start = done.find("code=").expect("signed in") + 5;
|
|
let code = &done[start..start + done[start..].find('&').unwrap()];
|
|
|
|
redeem_code(server, code, &pkce.verifier).unwrap()
|
|
}
|
|
|
|
struct Device {
|
|
db: PathBuf,
|
|
base: SyncBase,
|
|
remote: HttpRemote,
|
|
}
|
|
|
|
impl Device {
|
|
fn new(dir: &Path, name: &str, server: &str, token: &str) -> Self {
|
|
Self {
|
|
db: dir.join(format!("{name}.db")),
|
|
base: SyncBase::default(),
|
|
remote: HttpRemote::new(server, token),
|
|
}
|
|
}
|
|
|
|
fn sync(&mut self) -> mtgc_core::Result<mtgc_core::sync::remote::CycleReport> {
|
|
let path = self.db.clone();
|
|
sync_cycle(&self.remote, &mut self.base, |base, theirs| {
|
|
let conn = mtgc_core::db::open_library(&path)?;
|
|
merge_into(&conn, base, theirs)
|
|
})
|
|
}
|
|
|
|
fn set(&self, id: &str, qty: i64) {
|
|
let conn = mtgc_core::db::open_library(&self.db).unwrap();
|
|
let mut doc = read_doc(&conn).unwrap();
|
|
doc.cards.insert(id.into(), CardEntry { quantity: qty, ..Default::default() });
|
|
apply_doc(&conn, &doc).unwrap();
|
|
}
|
|
|
|
fn doc(&self) -> mtgc_core::sync::LibraryDoc {
|
|
read_doc(&mtgc_core::db::open_library(&self.db).unwrap()).unwrap()
|
|
}
|
|
|
|
fn qty(&self, id: &str) -> Option<i64> {
|
|
let conn = mtgc_core::db::open_library(&self.db).unwrap();
|
|
read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn printing_data_reaches_the_other_device_intact() {
|
|
// A version-2 document must be accepted by the server and come back out
|
|
// exactly as stored, or one device's tracked printings would vanish.
|
|
let (server, state) = spawn_server();
|
|
let hash = hash_password("correct horse battery").unwrap();
|
|
db::create_local_user(&state.db(), "bob", "Bob", &hash).unwrap();
|
|
let dir = std::env::temp_dir().join(format!("mtgc-e2e-printings-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
let phone_login = sign_in(&server, "bob", "correct horse battery");
|
|
let desktop_login = sign_in(&server, "bob", "correct horse battery");
|
|
let mut phone = Device::new(&dir, "phone", &server, &phone_login.token);
|
|
let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token);
|
|
|
|
{
|
|
let conn = mtgc_core::db::open_library(&phone.db).unwrap();
|
|
let mut doc = read_doc(&conn).unwrap();
|
|
let mut card = CardEntry { quantity: 3, ..Default::default() };
|
|
card.printings.insert(printing_key("p1", "foil"), 2);
|
|
doc.cards.insert("a".into(), card);
|
|
apply_doc(&conn, &doc).unwrap();
|
|
}
|
|
assert_eq!(phone.doc().version, 2);
|
|
assert!(phone.sync().unwrap().published);
|
|
desktop.sync().unwrap();
|
|
|
|
assert_eq!(desktop.doc(), phone.doc());
|
|
assert_eq!(desktop.doc().cards["a"].printings[&printing_key("p1", "foil")], 2);
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn two_devices_converge_through_the_real_server() {
|
|
let (server, state) = spawn_server();
|
|
let hash = hash_password("correct horse battery").unwrap();
|
|
db::create_local_user(&state.db(), "alice", "Alice", &hash).unwrap();
|
|
let dir = std::env::temp_dir().join(format!("mtgc-e2e-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
let info = server_info(&server).unwrap();
|
|
assert!(info.local_auth);
|
|
|
|
let phone_login = sign_in(&server, "alice", "correct horse battery");
|
|
let desktop_login = sign_in(&server, "alice", "correct horse battery");
|
|
assert_eq!(phone_login.user.id, desktop_login.user.id, "same account, same id");
|
|
assert_eq!(phone_login.user.method, "local");
|
|
|
|
let mut phone = Device::new(&dir, "phone", &server, &phone_login.token);
|
|
let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token);
|
|
|
|
phone.set("a", 1);
|
|
phone.set("b", 1);
|
|
assert!(phone.sync().unwrap().published);
|
|
desktop.sync().unwrap();
|
|
assert_eq!(desktop.qty("a"), Some(1));
|
|
|
|
// Offline edits on both, to different cards.
|
|
phone.set("a", 3);
|
|
desktop.set("b", 5);
|
|
phone.sync().unwrap();
|
|
desktop.sync().unwrap();
|
|
phone.sync().unwrap();
|
|
|
|
for d in [&phone, &desktop] {
|
|
assert_eq!(d.qty("a"), Some(3));
|
|
assert_eq!(d.qty("b"), Some(5));
|
|
}
|
|
assert!(!phone.sync().unwrap().published, "settled: nothing left to send");
|
|
|
|
// Revoking the phone from the desktop signs it out.
|
|
let me = desktop.remote.me().unwrap();
|
|
let phone_id = me.devices.iter().find(|d| !d.current).unwrap().id;
|
|
desktop.remote.revoke_device(phone_id).unwrap();
|
|
let err = phone.sync().unwrap_err();
|
|
assert_eq!(err.to_string(), UNAUTHORIZED);
|
|
|
|
desktop.remote.logout().unwrap();
|
|
assert!(desktop.sync().is_err());
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn the_app_and_server_agree_on_pkce() {
|
|
let p = Pkce::new();
|
|
assert_eq!(mtgc_server::auth::pkce_challenge(&p.verifier), p.challenge);
|
|
}
|
|
|
|
#[test]
|
|
fn an_address_that_is_not_an_mtgc_server_is_rejected() {
|
|
// Something that answers HTTP but isn't us.
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
std::thread::spawn(move || {
|
|
use std::io::{Read, Write};
|
|
if let Ok((mut s, _)) = listener.accept() {
|
|
let mut buf = [0u8; 1024];
|
|
let _ = s.read(&mut buf);
|
|
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");
|
|
}
|
|
});
|
|
let err = server_info(&format!("http://{addr}")).unwrap_err();
|
|
assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}");
|
|
}
|