mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
Extract orchestration (font registry + render pipeline) from the Axum backend into a standalone dreport-service crate. Backend becomes a thin HTTP adapter on top. Add dreport-ffi (cdylib) exposing the service through a stable C ABI with opaque handles, byte buffers, and thread-local error reporting. Build Dreport.Service + Dreport.AspNetCore NuGet packages under bindings/dotnet/, packing the host RID native binary via a generated nuspec. justfile recipes (nuget-publish, nuget-publish-all) build, pack, and push to the Gitea NuGet registry in one shot. Test coverage: 47 Rust + 38 C# (xUnit + WebApplicationFactory). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
9.7 KiB
Rust
298 lines
9.7 KiB
Rust
//! Integration tests for `DreportService`.
|
|
//!
|
|
//! These tests exercise the public API as it would be consumed by the Axum
|
|
//! adapter, the FFI layer, and any other host. Anything that breaks here
|
|
//! breaks behaviour for every consumer simultaneously, so failures should
|
|
//! be treated as a contract change.
|
|
|
|
use dreport_service::{DreportService, ServiceError};
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
|
|
const VALID_TEMPLATE: &str = r#"{
|
|
"id": "test",
|
|
"name": "Test",
|
|
"page": { "width": 210, "height": 297 },
|
|
"fonts": ["Noto Sans"],
|
|
"root": {
|
|
"id": "root",
|
|
"type": "container",
|
|
"position": { "type": "flow" },
|
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
|
"direction": "column",
|
|
"gap": 5,
|
|
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
|
"align": "stretch",
|
|
"justify": "start",
|
|
"style": {},
|
|
"children": [
|
|
{
|
|
"id": "title",
|
|
"type": "static_text",
|
|
"position": { "type": "flow" },
|
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
|
"content": "Hello dreport"
|
|
}
|
|
]
|
|
}
|
|
}"#;
|
|
|
|
const VALID_DATA: &str = r#"{}"#;
|
|
|
|
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Service initialization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn new_loads_embedded_fonts() {
|
|
let svc = DreportService::new();
|
|
assert!(
|
|
svc.font_family_count() >= 1,
|
|
"embedded-fonts feature should provide at least one family"
|
|
);
|
|
let names: Vec<String> = svc
|
|
.list_font_families()
|
|
.into_iter()
|
|
.map(|f| f.family.to_lowercase())
|
|
.collect();
|
|
assert!(
|
|
names.iter().any(|n| n.contains("noto")),
|
|
"Noto Sans family expected, got {:?}",
|
|
names
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_starts_with_no_fonts() {
|
|
let svc = DreportService::empty();
|
|
assert_eq!(svc.font_family_count(), 0);
|
|
assert!(svc.list_font_families().is_empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Font registration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn register_font_bytes_valid_ttf() {
|
|
let svc = DreportService::empty();
|
|
let registered = svc
|
|
.register_font_bytes(NOTO_SANS_REGULAR.to_vec())
|
|
.expect("valid TTF should register");
|
|
assert!(registered.family.to_lowercase().contains("noto"));
|
|
assert_eq!(svc.font_family_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn register_font_bytes_invalid_returns_parse_error() {
|
|
let svc = DreportService::empty();
|
|
let err = svc
|
|
.register_font_bytes(b"not a font".to_vec())
|
|
.expect_err("garbage bytes must not parse");
|
|
assert!(matches!(err, ServiceError::FontParseFailed));
|
|
assert_eq!(err.code(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn register_fonts_directory_loads_files() {
|
|
let svc = DreportService::empty();
|
|
let fonts_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts");
|
|
let count = svc
|
|
.register_fonts_directory(&fonts_dir)
|
|
.expect("assets/fonts must be readable");
|
|
assert!(count >= 1, "at least one font expected in assets/fonts");
|
|
assert!(svc.font_family_count() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn register_fonts_directory_missing_returns_error() {
|
|
let svc = DreportService::empty();
|
|
let err = svc
|
|
.register_fonts_directory("/no/such/dreport/fonts/path/zzz")
|
|
.expect_err("missing directory must error");
|
|
assert!(matches!(err, ServiceError::FontDirNotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn register_fonts_directory_skips_non_font_files() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("readme.txt"), b"hi").unwrap();
|
|
std::fs::write(dir.path().join("font.ttf"), NOTO_SANS_REGULAR).unwrap();
|
|
|
|
let svc = DreportService::empty();
|
|
let count = svc.register_fonts_directory(dir.path()).unwrap();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn register_fonts_directory_skips_invalid_font_silently() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("broken.ttf"), b"not a font").unwrap();
|
|
std::fs::write(dir.path().join("good.ttf"), NOTO_SANS_REGULAR).unwrap();
|
|
|
|
let svc = DreportService::empty();
|
|
let count = svc.register_fonts_directory(dir.path()).unwrap();
|
|
assert_eq!(count, 1, "only the good font should register");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Font lookup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn get_font_bytes_returns_data_for_known_variant() {
|
|
let svc = DreportService::new();
|
|
let bytes = svc
|
|
.get_font_bytes("Noto Sans", 400, false)
|
|
.expect("regular variant should exist");
|
|
assert!(!bytes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn get_font_bytes_case_insensitive() {
|
|
let svc = DreportService::new();
|
|
let lower = svc.get_font_bytes("noto sans", 400, false);
|
|
let mixed = svc.get_font_bytes("NoTo SaNs", 400, false);
|
|
assert!(lower.is_some());
|
|
assert!(mixed.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn get_font_bytes_unknown_returns_none() {
|
|
let svc = DreportService::new();
|
|
assert!(svc.get_font_bytes("DoesNotExist", 400, false).is_none());
|
|
assert!(svc.get_font_bytes("Noto Sans", 1234, false).is_none());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout + render pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn compute_layout_json_valid_template_returns_pages() {
|
|
let svc = DreportService::new();
|
|
let json = svc
|
|
.compute_layout_json(VALID_TEMPLATE, VALID_DATA)
|
|
.expect("layout should compute");
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
let pages = parsed
|
|
.get("pages")
|
|
.and_then(|p| p.as_array())
|
|
.expect("LayoutResult must contain pages array");
|
|
assert!(!pages.is_empty(), "at least one page expected");
|
|
}
|
|
|
|
#[test]
|
|
fn compute_layout_json_invalid_template_returns_typed_error() {
|
|
let svc = DreportService::new();
|
|
let err = svc
|
|
.compute_layout_json("{not json", VALID_DATA)
|
|
.expect_err("malformed template must error");
|
|
assert!(matches!(err, ServiceError::InvalidTemplateJson(_)));
|
|
assert_eq!(err.code(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_layout_json_invalid_data_returns_typed_error() {
|
|
let svc = DreportService::new();
|
|
let err = svc
|
|
.compute_layout_json(VALID_TEMPLATE, "{not json")
|
|
.expect_err("malformed data must error");
|
|
assert!(matches!(err, ServiceError::InvalidDataJson(_)));
|
|
assert_eq!(err.code(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn render_pdf_json_produces_pdf_with_magic_header() {
|
|
let svc = DreportService::new();
|
|
let pdf = svc
|
|
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
|
|
.expect("render must succeed");
|
|
assert!(
|
|
pdf.starts_with(b"%PDF-"),
|
|
"PDF magic header missing; got {:?}",
|
|
&pdf[..pdf.len().min(8)]
|
|
);
|
|
assert!(pdf.len() > 100, "PDF unexpectedly small");
|
|
}
|
|
|
|
#[test]
|
|
fn render_pdf_typed_matches_render_pdf_json() {
|
|
let svc = DreportService::new();
|
|
let from_json = svc
|
|
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
|
|
.expect("json render");
|
|
let template = serde_json::from_str(VALID_TEMPLATE).unwrap();
|
|
let data = serde_json::from_str(VALID_DATA).unwrap();
|
|
let from_typed = svc.render_pdf(&template, &data).expect("typed render");
|
|
// Producer headers vary on time; magic header + non-trivial size sufficient.
|
|
assert!(from_json.starts_with(b"%PDF-"));
|
|
assert!(from_typed.starts_with(b"%PDF-"));
|
|
assert_eq!(from_json.len(), from_typed.len());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Concurrency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn concurrent_renders_share_service_safely() {
|
|
let svc = Arc::new(DreportService::new());
|
|
let mut handles = Vec::new();
|
|
for _ in 0..8 {
|
|
let s = Arc::clone(&svc);
|
|
handles.push(thread::spawn(move || {
|
|
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
|
|
assert!(pdf.starts_with(b"%PDF-"));
|
|
}));
|
|
}
|
|
for h in handles {
|
|
h.join().expect("worker panic");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_register_and_render() {
|
|
let svc = Arc::new(DreportService::new());
|
|
let mut handles = Vec::new();
|
|
|
|
let writer_svc = Arc::clone(&svc);
|
|
handles.push(thread::spawn(move || {
|
|
for _ in 0..4 {
|
|
let _ = writer_svc.register_font_bytes(NOTO_SANS_REGULAR.to_vec());
|
|
}
|
|
}));
|
|
|
|
for _ in 0..4 {
|
|
let s = Arc::clone(&svc);
|
|
handles.push(thread::spawn(move || {
|
|
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
|
|
assert!(pdf.starts_with(b"%PDF-"));
|
|
}));
|
|
}
|
|
|
|
for h in handles {
|
|
h.join().expect("worker panic");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error display
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn service_error_codes_are_stable() {
|
|
// FFI consumers depend on these — changing them is a breaking change.
|
|
assert_eq!(ServiceError::InvalidTemplateJson("x".into()).code(), 1);
|
|
assert_eq!(ServiceError::InvalidDataJson("x".into()).code(), 2);
|
|
assert_eq!(ServiceError::FontParseFailed.code(), 3);
|
|
assert_eq!(ServiceError::FontDirNotFound("x".into()).code(), 4);
|
|
assert_eq!(ServiceError::FontDirRead("x".into()).code(), 5);
|
|
assert_eq!(ServiceError::LayoutFailed("x".into()).code(), 6);
|
|
assert_eq!(ServiceError::PdfFailed("x".into()).code(), 7);
|
|
assert_eq!(ServiceError::SerializationFailed("x".into()).code(), 8);
|
|
}
|