backbeat_sdk 0.5.0-beta

The library for interfacing with Backbeat.
#![allow(unreachable_pub, clippy::all, clippy::restriction)]
#![allow(clippy::pedantic, clippy::nursery, clippy::cargo)]

use std::collections::HashMap;

use backbeat_core::ValidGamemodeIdentifier;
use backbeat_core::{BackbeatFile, ChartData, ChartDesc, ChartFilename};
use backbeat_sdk::collections::CollectionUpsertStatus;
use backbeat_sdk::{CollectionClientError, StoreError};
use serde_json::json;

mod support;
use support::{new_test_store, spawn_collection_server, spawn_mutable_collection_server};

#[tokio::test(flavor = "multi_thread")]
async fn collection_fetch_rejects_http_error_status() {
	let (_temp, store) = new_test_store("collection_api_http_status");
	let base = spawn_collection_server(Vec::new()).await;

	let error = store.collection_fetch_header(&base).await.unwrap_err();

	assert!(matches!(
		error,
		StoreError::CollectionClient(CollectionClientError::HttpStatus {
			status: 404,
			ref url,
		}) if url == &format!("{base}/header.json")
	));
}

#[tokio::test(flavor = "multi_thread")]
async fn collection_contents_resolve_installed_bundles() {
	let (_temp, store) = new_test_store("collection_contents");
	let chart = ChartData::compress(b"#TITLE Test\n#ARTIST Backbeat\n").unwrap();
	let first = BackbeatFile {
		filename: ChartFilename::new("first.bms").unwrap(),
		assets: HashMap::new(),
		desc: ChartDesc::new("test").unwrap(),
		chart: chart.clone(),
	};
	let second = BackbeatFile {
		filename: ChartFilename::new("second.bms").unwrap(),
		assets: HashMap::new(),
		desc: ChartDesc::new("test").unwrap(),
		chart,
	};
	let first_bundle = store.import_bundle(&first).unwrap();
	let second_bundle = store.import_bundle(&second).unwrap();
	let chart_id = store
		.bundle_detail(first_bundle)
		.unwrap()
		.chart_ids
		.into_iter()
		.next()
		.expect("BMS inspection should produce a chart ID");
	let missing_id = format!("sha256/{}", "f".repeat(64));
	let table_json = json!({
		"name": "Resolved Table",
		"symbol": "L",
		"gamemode": "bms-7k",
		"updated": "2026-07-15T00:00:00Z",
		"tags": {},
		"assets": {},
		"levels": [{
			"level": "1",
			"tags": {},
			"charts": [
				{"id": chart_id.to_string(), "desc": "Installed", "tags": {"kind": "installed"}},
				{"id": missing_id, "desc": "Missing", "tags": {}}
			]
		}],
		"folders": [
			{"name": "Installed", "query": "tags.kind == \"installed\"", "tags": {}},
			{"name": "Invalid", "query": "level ==", "tags": {}}
		]
	});
	let course_json = json!({
		"name": "Resolved Course",
		"gamemode": "bms-7k",
		"updated": "2026-07-15T00:00:00Z",
		"tags": {},
		"assets": {},
		"charts": [
			{"id": format!("sha256/{}", first.chart_sha256()), "desc": "Installed", "tags": {}},
			{"id": missing_id, "desc": "Missing", "tags": {}}
		]
	});
	let pack_json = json!({
		"name": "Resolved Pack",
		"gamemode": "bms-7k",
		"updated": "2026-07-15T00:00:00Z",
		"tags": {},
		"assets": {},
		"bundles": [
			{"id": first_bundle.to_string(), "desc": "Installed", "tags": {}},
			{"id": format!("b-{}", "f".repeat(64)), "desc": "Missing", "tags": {}}
		]
	});
	let routes = vec![
		(
			"/table/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "table"}).to_string(),
		),
		("/table/data.bbtable".to_owned(), table_json.to_string()),
		(
			"/course/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "course"}).to_string(),
		),
		("/course/data.bbcourse".to_owned(), course_json.to_string()),
		(
			"/pack/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "pack"}).to_string(),
		),
		("/pack/data.bbpack".to_owned(), pack_json.to_string()),
	];
	let base = spawn_collection_server(routes).await;
	store
		.collection_fetch_upsert(&format!("{base}/table"))
		.await
		.unwrap();
	store
		.collection_fetch_upsert(&format!("{base}/course"))
		.await
		.unwrap();
	store
		.collection_fetch_upsert(&format!("{base}/pack"))
		.await
		.unwrap();

	let table = store.get_table(&format!("{base}/table")).unwrap();
	let resolved = table.levels[0].charts[0].bundle_id.unwrap();
	assert!([first_bundle, second_bundle].contains(&resolved));
	assert_eq!(table.levels[0].charts[1].bundle_id, None);
	assert_eq!(table.folders[0].charts.len(), 1);
	assert_eq!(table.folders[0].charts[0].id, chart_id);
	assert_eq!(table.folders[0].charts[0].desc, "Installed");
	assert_eq!(table.folders[0].charts[0].bundle_id, Some(resolved));
	assert!(table.folders[1].charts.is_empty());

	let course = store.get_course(&format!("{base}/course")).unwrap();
	assert!([first_bundle, second_bundle].contains(&course.charts[0].bundle_id.unwrap()));
	assert_eq!(course.charts[1].bundle_id, None);
	let pack = store.get_pack(&format!("{base}/pack")).unwrap();
	assert_eq!(pack.bundles[0].id, first_bundle);
	assert!(pack.bundles[0].installed);
	assert!(!pack.bundles[1].installed);

	let table_metadata = store.list_tables(&[]).unwrap();
	assert_eq!(table_metadata[0].gamemode.as_str(), "bms-7k");
	assert_eq!(table_metadata[0].installed, 1);
	assert_eq!(table_metadata[0].total, 2);
	let course_metadata = store.list_courses(&[]).unwrap();
	assert_eq!(course_metadata[0].gamemode.as_str(), "bms-7k");
	assert_eq!(course_metadata[0].installed, 1);
	assert_eq!(course_metadata[0].total, 2);
	let pack_metadata = store.list_packs(&[]).unwrap();
	assert_eq!(pack_metadata[0].gamemode.as_str(), "bms-7k");
	assert_eq!(pack_metadata[0].installed, 1);
	assert_eq!(pack_metadata[0].total, 2);

	let stats = store.stats().unwrap();
	assert_eq!(stats.tables, 1);
	assert_eq!(stats.courses, 1);
	assert_eq!(stats.packs, 1);
}

#[tokio::test(flavor = "multi_thread")]
async fn collection_upsert_round_trips_each_kind() {
	let (_temp, store) = new_test_store("collection_api");
	let table_json = json!({
		"name": "Table",
		"symbol": "L",
		"gamemode": "bms-7k",
		"updated": "2026-07-15T00:00:00Z",
		"tags": {"region": "jp"},
		"assets": {"banner.png": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
		"levels": [{
			"level": "1",
			"tags": {"color": "red"},
			"charts": [{"id": "sha256/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "desc": "Chart", "tags": {"mode": "normal"}}]
		}],
		"folders": [{"name": "All", "query": "level >= 1", "tags": {"kind": "all"}}]
	});
	let course_json = json!({
		"name": "Course",
		"gamemode": "bms-7k",
		"updated": "2026-07-15T00:00:00Z",
		"tags": {"course": "tag"},
		"assets": {"banner.png": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},
		"charts": [{"id": "sha256/dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "desc": "Course chart", "tags": {"stage": "1"}}]
	});
	let pack_json = json!({
		"name": "Pack",
		"gamemode": "sm-dance-single",
		"updated": "2026-07-15T00:00:00Z",
		"tags": {"series": "one"},
		"assets": {"banner.png": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"},
		"bundles": [{"id": "b-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "desc": "Bundle", "tags": {"pack": "tag"}}]
	});
	let routes = vec![
		(
			"/table/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "table"}).to_string(),
		),
		("/table/data.bbtable".to_owned(), table_json.to_string()),
		(
			"/course/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "course"}).to_string(),
		),
		("/course/data.bbcourse".to_owned(), course_json.to_string()),
		(
			"/pack/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "pack"}).to_string(),
		),
		("/pack/data.bbpack".to_owned(), pack_json.to_string()),
	];
	let base = spawn_collection_server(routes).await;

	assert_eq!(
		store
			.collection_fetch_upsert(&format!("{base}/table"))
			.await
			.unwrap()
			.status,
		CollectionUpsertStatus::Inserted
	);
	assert_eq!(
		store
			.collection_fetch_upsert(&format!("{base}/course"))
			.await
			.unwrap()
			.status,
		CollectionUpsertStatus::Inserted
	);
	assert_eq!(
		store
			.collection_fetch_upsert(&format!("{base}/pack"))
			.await
			.unwrap()
			.status,
		CollectionUpsertStatus::Inserted
	);

	let table = store.get_table(&format!("{base}/table")).unwrap();
	assert_eq!(table.name, "Table");
	assert_eq!(table.levels.len(), 1);
	assert_eq!(table.levels[0].charts.len(), 1);
	assert_eq!(table.levels[0].charts[0].bundle_id, None);
	assert_eq!(table.folders.len(), 1);
	let course = store.get_course(&format!("{base}/course")).unwrap();
	assert_eq!(course.name, "Course");
	assert_eq!(course.charts.len(), 1);
	assert_eq!(course.charts[0].bundle_id, None);
	let pack = store.get_pack(&format!("{base}/pack")).unwrap();
	assert_eq!(pack.name, "Pack");
	assert_eq!(pack.bundles.len(), 1);
	assert!(!pack.bundles[0].installed);

	let bms = ValidGamemodeIdentifier::new("bms-7k").unwrap();
	let stepmania = ValidGamemodeIdentifier::new("sm-dance-single").unwrap();

	assert_eq!(store.list_tables(&[]).unwrap().len(), 1);
	assert_eq!(
		store.list_tables(std::slice::from_ref(&bms)).unwrap().len(),
		1
	);
	assert!(
		store
			.list_tables(std::slice::from_ref(&stepmania))
			.unwrap()
			.is_empty()
	);

	assert_eq!(store.list_courses(&[]).unwrap().len(), 1);
	assert_eq!(
		store
			.list_courses(std::slice::from_ref(&bms))
			.unwrap()
			.len(),
		1
	);
	assert!(
		store
			.list_courses(std::slice::from_ref(&stepmania))
			.unwrap()
			.is_empty()
	);

	assert_eq!(store.list_packs(&[]).unwrap().len(), 1);
	assert!(
		store
			.list_packs(std::slice::from_ref(&bms))
			.unwrap()
			.is_empty()
	);
	assert_eq!(store.list_packs(&[bms, stepmania]).unwrap().len(), 1);
}

#[tokio::test(flavor = "multi_thread")]
async fn collection_upsert_rejects_timestamp_mismatch_without_writing() {
	let (_temp, store) = new_test_store("collection_api_mismatch");
	let base = spawn_collection_server(vec![
		("/table/header.json".to_owned(), json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "table"}).to_string()),
		("/table/data.bbtable".to_owned(), json!({"name": "Table", "symbol": "L", "gamemode": "bms-7k", "updated": "2026-07-15T00:00:01Z", "tags": {}, "assets": {}, "levels": [], "folders": []}).to_string()),
	]).await;
	let error = store
		.collection_fetch_upsert(&format!("{base}/table"))
		.await
		.unwrap_err();
	assert!(error.to_string().contains("timestamp changed"));
	assert!(store.get_table(&format!("{base}/table")).is_err());
}

#[tokio::test(flavor = "multi_thread")]
async fn collection_upsert_replaces_only_newer_content() {
	let (_temp, store) = new_test_store("collection_api_update");
	let old_body = json!({"name": "Old", "symbol": "L", "gamemode": "bms-7k", "updated": "2026-07-15T00:00:00Z", "tags": {}, "assets": {}, "levels": [], "folders": []}).to_string();
	let (base, routes) = spawn_mutable_collection_server(vec![
		(
			"/table/header.json".to_owned(),
			json!({"timestamp": "2026-07-15T00:00:00Z", "kind": "table"}).to_string(),
		),
		("/table/data.bbtable".to_owned(), old_body),
	])
	.await;
	let url = format!("{base}/table");
	assert_eq!(
		store.collection_fetch_upsert(&url).await.unwrap().status,
		CollectionUpsertStatus::Inserted
	);

	routes.lock().await.insert(
		"/table/header.json".to_owned(),
		json!({"timestamp": "2026-07-16T00:00:00Z", "kind": "table"}).to_string(),
	);
	routes.lock().await.insert(
		"/table/data.bbtable".to_owned(),
		json!({"name": "New", "symbol": "L", "gamemode": "bms-7k", "updated": "2026-07-16T00:00:00Z", "tags": {}, "assets": {}, "levels": [], "folders": []}).to_string(),
	);
	assert_eq!(
		store.collection_fetch_upsert(&url).await.unwrap().status,
		CollectionUpsertStatus::Updated
	);
	assert_eq!(store.get_table(&url).unwrap().name, "New");

	routes.lock().await.insert(
		"/table/header.json".to_owned(),
		json!({"timestamp": "2026-07-14T00:00:00Z", "kind": "table"}).to_string(),
	);
	routes.lock().await.insert(
		"/table/data.bbtable".to_owned(),
		json!({"name": "Older", "symbol": "L", "gamemode": "bms-7k", "updated": "2026-07-14T00:00:00Z", "tags": {}, "assets": {}, "levels": [], "folders": []}).to_string(),
	);
	assert_eq!(
		store.collection_fetch_upsert(&url).await.unwrap().status,
		CollectionUpsertStatus::TimestampUnchanged
	);
	assert_eq!(store.get_table(&url).unwrap().name, "New");
}