mirror of
https://github.com/Shu-Ying/farm_service.git
synced 2026-11-03 03:00:02 +08:00
add # 新增对资源文件的维护
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,256 @@
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use farm_service::{
|
||||
assets::digest,
|
||||
catalogue::{self, CatalogueStore},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn publish(root: &Path, version: &str, plants: &[u8]) -> Value {
|
||||
let content = BTreeMap::from([
|
||||
("plants.json", plants),
|
||||
("metadata.json", b"{\"schema\":1}".as_slice()),
|
||||
("assets.zip", b"PK\x03\x04pictures".as_slice()),
|
||||
("land-soils.zip", b"PK\x03\x04soils".as_slice()),
|
||||
]);
|
||||
let files: BTreeMap<_, _> = content
|
||||
.iter()
|
||||
.map(|(name, bytes)| {
|
||||
(
|
||||
*name,
|
||||
json!({"sha256": digest(bytes), "size_bytes": bytes.len()}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let release = digest(&serde_json::to_vec(&files).unwrap());
|
||||
let manifest = json!({"schema": 1, "version": version, "release": release,
|
||||
"files": files});
|
||||
let directory = root.join("releases").join(&release);
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for (name, bytes) in content {
|
||||
std::fs::write(directory.join(name), bytes).unwrap();
|
||||
}
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
std::fs::write(directory.join("manifest.json"), &bytes).unwrap();
|
||||
std::fs::write(root.join("manifest.json"), bytes).unwrap();
|
||||
manifest
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_latest_history_etags_and_verified_memory_snapshot() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let old = publish(directory.path(), "one", b"{\"schema\":1,\"plants\":[1]}");
|
||||
let latest = publish(directory.path(), "two", b"{\"schema\":1,\"plants\":[2]}");
|
||||
let store = CatalogueStore::load(directory.path()).unwrap();
|
||||
let app = catalogue::router(Some(store));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/catalogue/manifest.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let etag = response.headers()["etag"].clone();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&to_bytes(response.into_body(), 16384).await.unwrap())
|
||||
.unwrap(),
|
||||
latest
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/catalogue/manifest.json")
|
||||
.header("if-none-match", etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
||||
let release = old["release"].as_str().unwrap();
|
||||
std::fs::write(
|
||||
directory
|
||||
.path()
|
||||
.join("releases")
|
||||
.join(release)
|
||||
.join("plants.json"),
|
||||
b"changed",
|
||||
)
|
||||
.unwrap();
|
||||
let uri = format!("/v1/catalogue/releases/{release}/plants.json");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()["cache-control"],
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
let etag = response.headers()["etag"].clone();
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 4096).await.unwrap(),
|
||||
b"{\"schema\":1,\"plants\":[1]}".as_slice()
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header("if-none-match", etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
||||
for file in [
|
||||
"metadata.json",
|
||||
"assets.zip",
|
||||
"land-soils.zip",
|
||||
"manifest.json",
|
||||
] {
|
||||
let uri = format!("/v1/catalogue/releases/{release}/{file}");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_unknown_paths_and_profile_authentication_remain_separate() {
|
||||
let response = catalogue::router(None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/catalogue/manifest.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let manifest = publish(directory.path(), "test", b"{}");
|
||||
let store = CatalogueStore::load(directory.path()).unwrap();
|
||||
let pool = farm_service::db::open_database(&directory.path().join("test.sqlite3"))
|
||||
.await
|
||||
.unwrap();
|
||||
let app = farm_service::api::router(pool.clone(), "test-only-token".into())
|
||||
.merge(catalogue::router(Some(store)));
|
||||
let release = manifest["release"].as_str().unwrap();
|
||||
for (uri, expected) in [
|
||||
(
|
||||
format!("/v1/catalogue/releases/{release}/secret.txt"),
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
(
|
||||
"/v1/catalogue/releases/unknown/plants.json".into(),
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
("/v1/catalogue/manifest.json".into(), StatusCode::OK),
|
||||
("/v1/users/qq/123456".into(), StatusCode::UNAUTHORIZED),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected);
|
||||
}
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_corrupt_oversized_or_unbound_release_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let manifest = publish(directory.path(), "test", b"{}");
|
||||
let release = manifest["release"].as_str().unwrap();
|
||||
let file = directory
|
||||
.path()
|
||||
.join("releases")
|
||||
.join(release)
|
||||
.join("plants.json");
|
||||
std::fs::write(&file, b"bad").unwrap();
|
||||
assert!(CatalogueStore::load(directory.path()).is_err());
|
||||
std::fs::write(&file, b"[]").unwrap();
|
||||
assert!(CatalogueStore::load(directory.path()).is_err());
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert!(CatalogueStore::load(directory.path()).is_err());
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
let mut invalid = manifest.clone();
|
||||
invalid["files"]["plants.json"]["size_bytes"] = json!(2 * 1024 * 1024 + 1);
|
||||
std::fs::write(
|
||||
directory.path().join("manifest.json"),
|
||||
serde_json::to_vec(&invalid).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(CatalogueStore::load(directory.path()).is_err());
|
||||
invalid = manifest.clone();
|
||||
invalid["release"] = json!("a".repeat(64));
|
||||
std::fs::write(
|
||||
directory.path().join("manifest.json"),
|
||||
serde_json::to_vec(&invalid).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(CatalogueStore::load(directory.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_schema_fields_missing_latest_history_and_bad_historical_manifest() {
|
||||
for kind in ["schema", "extra", "missing", "label", "history"] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut manifest = publish(directory.path(), "test", b"{}");
|
||||
let release = manifest["release"].as_str().unwrap().to_string();
|
||||
match kind {
|
||||
"schema" => manifest["schema"] = json!(2),
|
||||
"extra" => {
|
||||
manifest["files"]["private.txt"] =
|
||||
json!({"sha256": "a".repeat(64), "size_bytes": 1})
|
||||
}
|
||||
"missing" => {
|
||||
std::fs::remove_dir_all(directory.path().join("releases").join(&release)).unwrap();
|
||||
}
|
||||
"label" => manifest["version"] = json!("different-label"),
|
||||
"history" => {
|
||||
let old = publish(directory.path(), "old", b"[]");
|
||||
std::fs::write(
|
||||
directory
|
||||
.path()
|
||||
.join("releases")
|
||||
.join(old["release"].as_str().unwrap())
|
||||
.join("manifest.json"),
|
||||
b"{}",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
std::fs::write(
|
||||
directory.path().join("manifest.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
CatalogueStore::load(directory.path()).is_err(),
|
||||
"accepted {kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independently_published_repository_release_is_accepted_by_rust() {
|
||||
CatalogueStore::load(&Path::new(env!("CARGO_MANIFEST_DIR")).join("catalogue")).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Offline publication contract; every fixture lives in a temporary directory."""
|
||||
|
||||
import hashlib
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/prepare_catalogue.py"
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
|
||||
|
||||
class PublicationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.source = self.root / "source"
|
||||
self.output = self.root / "output"
|
||||
(self.source / "images/plants").mkdir(parents=True)
|
||||
Image.new("RGBA", (2, 3), "red").save(
|
||||
self.source / "images/plants/carrot.png")
|
||||
self.plant = dict(id=1, name="Carrot", level=0, buy=1, isVip=0,
|
||||
vipBuy=0, experience=1, harvest=1, price=2,
|
||||
time=1, crop=1, phase="1,2", general=1,
|
||||
offsetH=0, offsetW=0, offsetX=0, offsetY=0)
|
||||
self.save_plants([self.plant])
|
||||
self.metadata = {
|
||||
"schema": 1, "canvas": [2, 3], "soil_size": [2, 3],
|
||||
"positions": [{"x": 1, "y": 1} for _ in range(30)],
|
||||
"plants": {}, "images": {},
|
||||
"placement": {"schema": 4, "mode": "visible_contact_center",
|
||||
"common_scale": 1, "growth_scale": 1,
|
||||
"reference_soil_height": 1, "reference_soil_width": 1,
|
||||
"contact_alpha_threshold": 32, "contact_band_fraction": 0.2,
|
||||
"scene_transform": [[1, 0], [0, 1]]},
|
||||
"pest": {"display_size": [2, 3],
|
||||
"frames": ["pest/1.png", "pest/2.png", "pest/3.png"],
|
||||
"positions": [[1, 1], [1, 2], [2, 1]]},
|
||||
"panel": {"reference_canvas": [2, 3], "avatar": [0, 0, 1, 1],
|
||||
"fields": [{"field": field, "rect": [0, 0, 1, 1],
|
||||
"color": [0, 0, 0], "alignment": 3} for field in
|
||||
["name", "experience", "level", "point", "vip_point"]],
|
||||
"sprites": [{"sprite": "background/ripe.png", "rect": [0, 0, 1, 1]}]},
|
||||
"decorations": {theme: [{"position": [1, 1], "size": [2, 3],
|
||||
"layer": "background", "sprite": "background/ripe.png"}]
|
||||
for theme in ["default", "spring", "summer", "autumn", "winter", "bee"]},
|
||||
}
|
||||
self.save_metadata(self.metadata)
|
||||
for name in ["webgl/seed.png", "webgl/wither.png", "background/ripe.png",
|
||||
"soil/草土地.png", "soil/普通土地.png", *self.metadata["pest"]["frames"]]:
|
||||
path = self.source / "images" / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(self.source / "images/plants/carrot.png", path)
|
||||
with zipfile.ZipFile(self.source / "land-soils.zip", "w") as archive:
|
||||
for name in ("soil/红土地.png", "soil/黑土地.png", "soil/金土地.png"):
|
||||
archive.writestr(name, (self.source /
|
||||
"images/plants/carrot.png").read_bytes())
|
||||
|
||||
def save_metadata(self, metadata):
|
||||
(self.source / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
|
||||
|
||||
def save_plants(self, plants):
|
||||
(self.source / "plants.json").write_text(json.dumps({
|
||||
"schema": 1, "plants": plants}), encoding="utf-8")
|
||||
|
||||
def run_tool(self, *extra):
|
||||
return subprocess.run([sys.executable, str(SCRIPT), "--source",
|
||||
str(self.source), "--output", str(self.output), "--version",
|
||||
"test-1", *extra], capture_output=True, text=True)
|
||||
|
||||
def publish(self):
|
||||
result = self.run_tool()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads((self.output / "manifest.json").read_bytes())
|
||||
|
||||
def test_release_binds_four_files_and_is_reproducible(self):
|
||||
manifest = self.publish()
|
||||
self.assertEqual(set(manifest["files"]), {
|
||||
"plants.json", "metadata.json", "assets.zip", "land-soils.zip"})
|
||||
canonical = json.dumps(manifest["files"], sort_keys=True,
|
||||
separators=(",", ":")).encode()
|
||||
self.assertEqual(manifest["release"], hashlib.sha256(canonical).hexdigest())
|
||||
release = self.output / "releases" / manifest["release"]
|
||||
before = {p.name: p.read_bytes() for p in release.iterdir()}
|
||||
self.publish()
|
||||
self.assertEqual(before, {p.name: p.read_bytes() for p in release.iterdir()})
|
||||
metadata = json.loads(before["metadata.json"])
|
||||
self.assertEqual(metadata["plants"]["Carrot"]["phase"], "1,2")
|
||||
self.assertEqual(metadata["images"]["plants/carrot.png"]["size"], [2, 3])
|
||||
self.assertEqual(self.run_tool("--check").returncode, 0)
|
||||
self.plant["price"] = 3
|
||||
self.save_plants([self.plant])
|
||||
self.assertNotEqual(self.run_tool("--check").returncode, 0)
|
||||
self.assertEqual(json.loads((self.output / "manifest.json").read_bytes()), manifest)
|
||||
|
||||
def test_rejects_removed_or_renamed_ids_without_changing_latest(self):
|
||||
manifest = self.publish()
|
||||
for rows in ([], [dict(self.plant, name="Renamed")]):
|
||||
self.save_plants(rows)
|
||||
self.assertNotEqual(self.run_tool("--version", "test-2").returncode, 0)
|
||||
self.assertEqual(json.loads((self.output / "manifest.json").read_bytes()), manifest)
|
||||
|
||||
def test_rejects_removed_images_corrupt_files_and_duplicate_ids(self):
|
||||
manifest = self.publish()
|
||||
(self.source / "images/plants/carrot.png").unlink()
|
||||
self.assertNotEqual(self.run_tool("--version", "test-2").returncode, 0)
|
||||
Image.new("RGB", (1, 1)).save(self.source / "images/plants/carrot.png")
|
||||
self.save_plants([self.plant, self.plant])
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
self.save_plants([self.plant])
|
||||
release = self.output / "releases" / manifest["release"]
|
||||
(release / "plants.json").write_bytes(b"corrupt")
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
|
||||
def test_rejects_unsafe_soil_zip_before_publication(self):
|
||||
with zipfile.ZipFile(self.source / "land-soils.zip", "w") as archive:
|
||||
archive.writestr("../escape.png", b"bad")
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
self.assertFalse((self.output / "manifest.json").exists())
|
||||
|
||||
def test_numeric_update_and_new_id_keep_old_release_immutable(self):
|
||||
first = self.publish()
|
||||
old_release = self.output / "releases" / first["release"]
|
||||
old_bytes = (old_release / "plants.json").read_bytes()
|
||||
self.plant["price"] = 20
|
||||
second_plant = dict(self.plant, id=2, name="Turnip")
|
||||
self.save_plants([self.plant, second_plant])
|
||||
result = self.run_tool("--version", "test-2")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
second = json.loads((self.output / "manifest.json").read_bytes())
|
||||
self.assertNotEqual(second["release"], first["release"])
|
||||
self.assertEqual((old_release / "plants.json").read_bytes(), old_bytes)
|
||||
self.assertEqual(self.run_tool("--version", "test-2", "--check").returncode, 0)
|
||||
|
||||
def test_combined_expanded_limit_applies_to_both_archives(self):
|
||||
# A valid image twice is permitted individually, but the total is bounded.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("prepare_catalogue", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
picture_size = (self.source / "images/plants/carrot.png").stat().st_size
|
||||
module.MAX_EXPANDED = picture_size * 10
|
||||
with self.assertRaises(ValueError):
|
||||
module.prepare(self.source, self.output, "test-1")
|
||||
|
||||
def test_rejects_values_outside_client_domain_limits(self):
|
||||
for changes in ({"price": 2**63}, {"time": 2**63 // 3600 + 1},
|
||||
{"name": "x" * 129}, {"phase": "1,9223372036854775808"}):
|
||||
with self.subTest(changes=changes):
|
||||
shutil.rmtree(self.output, ignore_errors=True)
|
||||
self.save_plants([dict(self.plant, **changes)])
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
|
||||
def test_rejects_names_that_cannot_be_safe_crop_asset_components(self):
|
||||
for name in (".", "..", "Carrot/Turnip", "Carrot\\Turnip"):
|
||||
with self.subTest(name=name):
|
||||
shutil.rmtree(self.output, ignore_errors=True)
|
||||
self.save_plants([dict(self.plant, name=name)])
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
self.assertFalse((self.output / "manifest.json").exists())
|
||||
|
||||
def test_oversized_integer_coordinates_raise_validation_error(self):
|
||||
from catalogue_validation import numbers
|
||||
for value in (10**1000, -(10**1000)):
|
||||
with self.subTest(value_sign=value > 0):
|
||||
try:
|
||||
numbers([value], 1)
|
||||
except Exception as error:
|
||||
self.assertIsInstance(error, ValueError)
|
||||
else:
|
||||
self.fail("oversized coordinate was accepted")
|
||||
|
||||
def test_rejects_malformed_metadata_before_publication(self):
|
||||
changes = [
|
||||
lambda m: m.pop("soil_size"),
|
||||
lambda m: m.update(schema=True),
|
||||
lambda m: m.update(canvas=[True, 3]),
|
||||
lambda m: m.update(positions=[{"x": 1, "y": 1}]),
|
||||
lambda m: m["placement"].update(schema=True),
|
||||
lambda m: m["placement"].update(common_scale=float("nan")),
|
||||
lambda m: m["placement"].update(growth_scale=-1),
|
||||
lambda m: m["placement"].update(scene_transform=[[1, 1], [1, 1]]),
|
||||
lambda m: m["pest"].update(frames=["pest/1.png"] * 3),
|
||||
lambda m: m["pest"].update(display_size=[8193, 1]),
|
||||
lambda m: m["panel"].update(fields=[]),
|
||||
lambda m: m["panel"]["fields"][0].update(color=[0, float("inf"), 0]),
|
||||
lambda m: m["panel"]["fields"][0].update(alignment=True),
|
||||
lambda m: m["panel"]["sprites"][0].update(rect=[0, 0, 0, 1]),
|
||||
lambda m: m["decorations"].pop("winter"),
|
||||
lambda m: m["decorations"]["winter"][0].update(layer="unknown"),
|
||||
]
|
||||
for change in changes:
|
||||
with self.subTest(change=change):
|
||||
shutil.rmtree(self.output, ignore_errors=True)
|
||||
metadata = copy.deepcopy(self.metadata)
|
||||
change(metadata)
|
||||
self.save_metadata(metadata)
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
self.assertFalse((self.output / "manifest.json").exists())
|
||||
|
||||
def test_rejects_required_missing_sprite_references(self):
|
||||
for kind in ("required", "pest", "panel", "decoration"):
|
||||
with self.subTest(kind=kind):
|
||||
shutil.rmtree(self.output, ignore_errors=True)
|
||||
metadata = copy.deepcopy(self.metadata)
|
||||
required = self.source / "images/webgl/seed.png"
|
||||
if kind == "required":
|
||||
content = required.read_bytes()
|
||||
required.unlink()
|
||||
elif kind == "pest":
|
||||
metadata["pest"]["frames"][0] = "missing.png"
|
||||
elif kind == "panel":
|
||||
metadata["panel"]["sprites"][0]["sprite"] = "missing.png"
|
||||
else:
|
||||
metadata["decorations"]["winter"][0]["sprite"] = "missing.png"
|
||||
self.save_metadata(metadata)
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
if kind == "required":
|
||||
required.write_bytes(content)
|
||||
|
||||
def test_rejects_incomplete_soil_bundle(self):
|
||||
with zipfile.ZipFile(self.source / "land-soils.zip", "w") as archive:
|
||||
archive.writestr("soil/红土地.png", (self.source /
|
||||
"images/plants/carrot.png").read_bytes())
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
|
||||
def test_rejects_reusing_version_label_for_changed_release(self):
|
||||
first = self.publish()
|
||||
self.plant["price"] = 30
|
||||
self.save_plants([self.plant])
|
||||
self.assertNotEqual(self.run_tool().returncode, 0)
|
||||
self.assertEqual(json.loads((self.output / "manifest.json").read_bytes()), first)
|
||||
|
||||
def paused_publisher(self, source, version):
|
||||
ready, proceed = self.root / "ready", self.root / "proceed"
|
||||
code = """
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import prepare_catalogue as module
|
||||
original = module.build
|
||||
def paused(*args):
|
||||
Path(sys.argv[5]).touch()
|
||||
deadline = time.monotonic() + 20
|
||||
while not Path(sys.argv[6]).exists():
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('test barrier timed out')
|
||||
time.sleep(0.01)
|
||||
return original(*args)
|
||||
module.build = paused
|
||||
module.prepare(Path(sys.argv[2]), Path(sys.argv[3]), sys.argv[4])
|
||||
"""
|
||||
process = subprocess.Popen([sys.executable, "-c", code,
|
||||
str(SCRIPT.parent), str(source), str(self.output), version,
|
||||
str(ready), str(proceed)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.addCleanup(lambda: process.kill() if process.poll() is None else None)
|
||||
deadline = time.monotonic() + 10
|
||||
while not ready.exists() and time.monotonic() < deadline:
|
||||
self.assertIsNone(process.poll())
|
||||
time.sleep(0.01)
|
||||
self.assertTrue(ready.exists())
|
||||
return process, proceed
|
||||
|
||||
def test_cross_process_publishers_cannot_erase_concurrently_added_ids(self):
|
||||
self.publish()
|
||||
newer = self.root / "newer"
|
||||
shutil.copytree(self.source, newer)
|
||||
(newer / "plants.json").write_text(json.dumps({"schema": 1, "plants": [
|
||||
self.plant, dict(self.plant, id=2, name="Turnip")]}), encoding="utf-8")
|
||||
self.plant["price"] = 50
|
||||
self.save_plants([self.plant])
|
||||
old, proceed = self.paused_publisher(self.source, "test-old-writer")
|
||||
added = subprocess.Popen([sys.executable, str(SCRIPT), "--source", str(newer),
|
||||
"--output", str(self.output), "--version", "test-new-writer"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.addCleanup(lambda: added.kill() if added.poll() is None else None)
|
||||
try:
|
||||
added.wait(timeout=1.5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
proceed.touch()
|
||||
old_out, old_error = old.communicate(timeout=10)
|
||||
added_out, added_error = added.communicate(timeout=10)
|
||||
self.assertEqual(old.returncode, 0, (old_out, old_error))
|
||||
self.assertEqual(added.returncode, 0, (added_out, added_error))
|
||||
latest = json.loads((self.output / "manifest.json").read_bytes())
|
||||
rows = json.loads((self.output / "releases" / latest["release"] /
|
||||
"plants.json").read_bytes())["plants"]
|
||||
self.assertEqual({row["id"] for row in rows}, {1, 2})
|
||||
|
||||
def test_process_exit_releases_publication_lock(self):
|
||||
self.publish()
|
||||
process, _ = self.paused_publisher(self.source, "test-interrupted")
|
||||
process.kill()
|
||||
process.communicate(timeout=10)
|
||||
self.assertEqual(self.run_tool().returncode, 0)
|
||||
|
||||
def test_threads_publish_without_losing_new_ids(self):
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
import prepare_catalogue as module
|
||||
|
||||
self.publish()
|
||||
newer = self.root / "newer"
|
||||
shutil.copytree(self.source, newer)
|
||||
(newer / "plants.json").write_text(json.dumps({"schema": 1, "plants": [
|
||||
self.plant, dict(self.plant, id=2, name="Turnip")]}), encoding="utf-8")
|
||||
self.plant["price"] = 50
|
||||
self.save_plants([self.plant])
|
||||
ready, proceed = threading.Event(), threading.Event()
|
||||
original = module.build
|
||||
|
||||
def paused(source, version):
|
||||
if version == "old-thread":
|
||||
ready.set()
|
||||
if not proceed.wait(10):
|
||||
raise RuntimeError("test barrier timed out")
|
||||
return original(source, version)
|
||||
|
||||
with patch.object(module, "build", paused), ThreadPoolExecutor(2) as executor:
|
||||
old = executor.submit(module.prepare, self.source, self.output, "old-thread")
|
||||
self.assertTrue(ready.wait(10))
|
||||
added = executor.submit(module.prepare, newer, self.output, "new-thread")
|
||||
try:
|
||||
added.result(timeout=0.5)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
proceed.set()
|
||||
old.result(timeout=10)
|
||||
latest = added.result(timeout=10)
|
||||
rows = json.loads((self.output / "releases" / latest["release"] /
|
||||
"plants.json").read_bytes())["plants"]
|
||||
self.assertEqual({row["id"] for row in rows}, {1, 2})
|
||||
|
||||
def test_check_does_not_create_or_modify_output_files(self):
|
||||
self.assertNotEqual(self.run_tool("--check").returncode, 0)
|
||||
self.assertFalse(self.output.exists())
|
||||
self.publish()
|
||||
before = {p.relative_to(self.output): (p.read_bytes(), p.stat().st_mtime_ns)
|
||||
for p in self.output.rglob("*") if p.is_file()}
|
||||
self.assertEqual(self.run_tool("--check").returncode, 0)
|
||||
after = {p.relative_to(self.output): (p.read_bytes(), p.stat().st_mtime_ns)
|
||||
for p in self.output.rglob("*") if p.is_file()}
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_interrupted_staging_manifest_does_not_reserve_version_label(self):
|
||||
first = self.publish()
|
||||
orphan = self.output / "releases/.prepare-interrupted"
|
||||
orphan.mkdir()
|
||||
orphan_manifest = dict(first, version="test-2")
|
||||
(orphan / "manifest.json").write_text(json.dumps(orphan_manifest), encoding="utf-8")
|
||||
self.plant["price"] = 25
|
||||
self.save_plants([self.plant])
|
||||
result = self.run_tool("--version", "test-2")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user