pinakes-plugin-api: expand test coverage; fix merge conflicts

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I34e7c6d382ab7f4b6cf98ede9b7116056a6a6964
This commit is contained in:
raf 2026-02-05 11:11:49 +03:00
commit 3abfe6a79b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
9 changed files with 631 additions and 128 deletions

1
Cargo.lock generated
View file

@ -4904,6 +4904,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"uuid",
"wit-bindgen 0.39.0",

View file

@ -12,6 +12,7 @@ resolver = "3"
edition = "2024"
version = "0.1.0"
license = "MIT"
readme = true
[workspace.dependencies]
# Async runtime

View file

@ -25,3 +25,6 @@ wit-bindgen = { workspace = true, optional = true }
[features]
default = []
wasm = ["wit-bindgen"]
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] }

View file

@ -15,6 +15,7 @@ pub mod wasm;
pub use manifest::PluginManifest;
pub use types::*;
pub use wasm::host_functions;
/// Plugin API version - plugins must match this version
pub const PLUGIN_API_VERSION: &str = "1.0";

View file

@ -0,0 +1,556 @@
use pinakes_plugin_api::{
Capabilities, EnvironmentCapability, Event, EventType,
ExtractedMetadata, FilesystemCapability, HealthStatus,
MediaTypeDefinition, NetworkCapability, Plugin, PluginContext,
PluginError, PluginMetadata, PluginResult, SearchIndexItem, SearchQuery,
SearchResult, SearchStats, ThumbnailFormat, ThumbnailInfo, ThumbnailOptions,
};
use pinakes_plugin_api::wasm::{HttpRequest, HttpResponse, LogLevel, LogMessage};
use std::collections::HashMap;
use std::path::PathBuf;
use async_trait::async_trait;
struct TestPlugin {
initialized: bool,
shutdown: bool,
health_status: HealthStatus,
metadata: PluginMetadata,
}
impl TestPlugin {
fn new() -> Self {
Self {
initialized: false,
shutdown: false,
health_status: HealthStatus {
healthy: true,
message: Some("OK".to_string()),
metrics: HashMap::new(),
},
metadata: PluginMetadata {
id: "test-plugin".to_string(),
name: "Test Plugin".to_string(),
version: "1.0.0".to_string(),
author: "Test Author".to_string(),
description: "A test plugin".to_string(),
api_version: "1.0".to_string(),
capabilities_required: Capabilities::default(),
},
}
}
}
#[async_trait]
impl Plugin for TestPlugin {
fn metadata(&self) -> &PluginMetadata {
&self.metadata
}
async fn initialize(&mut self, _context: PluginContext) -> PluginResult<()> {
self.initialized = true;
Ok(())
}
async fn shutdown(&mut self) -> PluginResult<()> {
self.shutdown = true;
Ok(())
}
async fn health_check(&self) -> PluginResult<HealthStatus> {
Ok(self.health_status.clone())
}
}
#[tokio::test]
async fn test_plugin_context_creation() {
let context = PluginContext {
data_dir: PathBuf::from("/data/test-plugin"),
cache_dir: PathBuf::from("/cache/test-plugin"),
config: HashMap::from([
("enabled".to_string(), serde_json::json!(true)),
("max_items".to_string(), serde_json::json!(100)),
]),
capabilities: Capabilities {
filesystem: FilesystemCapability {
read: vec![PathBuf::from("/data")],
write: vec![PathBuf::from("/data")],
},
network: NetworkCapability {
enabled: true,
allowed_domains: Some(vec!["api.example.com".to_string()]),
},
environment: EnvironmentCapability {
enabled: true,
allowed_vars: Some(vec!["API_KEY".to_string()]),
},
max_memory_bytes: Some(256 * 1024 * 1024),
max_cpu_time_ms: Some(30000),
},
};
assert_eq!(context.data_dir, PathBuf::from("/data/test-plugin"));
assert_eq!(context.cache_dir, PathBuf::from("/cache/test-plugin"));
assert_eq!(context.config.get("enabled").unwrap(), &serde_json::json!(true));
assert!(context.capabilities.network.enabled);
assert_eq!(
context.capabilities.max_memory_bytes,
Some(256 * 1024 * 1024)
);
}
#[tokio::test]
async fn test_plugin_context_fields() {
let context = PluginContext {
data_dir: PathBuf::from("/custom/data"),
cache_dir: PathBuf::from("/custom/cache"),
config: HashMap::new(),
capabilities: Capabilities::default(),
};
assert_eq!(context.data_dir, PathBuf::from("/custom/data"));
assert_eq!(context.cache_dir, PathBuf::from("/custom/cache"));
}
#[tokio::test]
async fn test_plugin_lifecycle() {
let mut plugin = TestPlugin::new();
assert!(!plugin.initialized);
assert!(!plugin.shutdown);
let context = PluginContext {
data_dir: PathBuf::from("/data"),
cache_dir: PathBuf::from("/cache"),
config: HashMap::new(),
capabilities: Capabilities::default(),
};
plugin.initialize(context).await.unwrap();
assert!(plugin.initialized);
let health = plugin.health_check().await.unwrap();
assert!(health.healthy);
assert_eq!(health.message, Some("OK".to_string()));
plugin.shutdown().await.unwrap();
assert!(plugin.shutdown);
}
#[tokio::test]
async fn test_extracted_metadata_structure() {
let metadata = ExtractedMetadata {
title: Some("Test Document".to_string()),
description: Some("A test document".to_string()),
author: Some("John Doe".to_string()),
created_at: Some("2024-01-15T10:30:00Z".to_string()),
duration_secs: Some(120.5),
width: Some(1920),
height: Some(1080),
file_size_bytes: Some(1_500_000),
codec: Some("h264".to_string()),
bitrate_kbps: Some(5000),
custom_fields: HashMap::from([
("color_space".to_string(), serde_json::json!("sRGB")),
("orientation".to_string(), serde_json::json!(90)),
]),
tags: vec!["test".to_string(), "document".to_string()],
};
assert_eq!(metadata.title, Some("Test Document".to_string()));
assert_eq!(metadata.width, Some(1920));
assert_eq!(metadata.height, Some(1080));
assert_eq!(metadata.tags.len(), 2);
assert_eq!(metadata.custom_fields.get("color_space").unwrap(), "sRGB");
}
#[tokio::test]
async fn test_search_query_serialization() {
let query = SearchQuery {
query_text: "nature landscape".to_string(),
filters: HashMap::from([
("type".to_string(), serde_json::json!("image")),
("year".to_string(), serde_json::json!(2023)),
]),
limit: 50,
offset: 0,
};
let serialized = serde_json::to_string(&query).unwrap();
let deserialized: SearchQuery = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.query_text, "nature landscape");
assert_eq!(deserialized.limit, 50);
assert_eq!(deserialized.offset, 0);
assert_eq!(deserialized.filters.get("type").unwrap(), "image");
}
#[tokio::test]
async fn test_search_result_serialization() {
let result = SearchResult {
id: "media-123".to_string(),
score: 0.95,
highlights: vec!["matched <hit>content</hit>".to_string()],
};
let serialized = serde_json::to_string(&result).unwrap();
let deserialized: SearchResult = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.id, "media-123");
assert!((deserialized.score - 0.95).abs() < 0.001);
assert_eq!(deserialized.highlights.len(), 1);
}
#[tokio::test]
async fn test_search_stats_serialization() {
let stats = SearchStats {
total_indexed: 10_000,
index_size_bytes: 500_000_000,
last_update: Some("2024-01-15T12:00:00Z".to_string()),
};
let serialized = serde_json::to_string(&stats).unwrap();
let deserialized: SearchStats = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.total_indexed, 10_000);
assert_eq!(deserialized.index_size_bytes, 500_000_000);
assert!(deserialized.last_update.is_some());
}
#[tokio::test]
async fn test_thumbnail_options_serialization() {
let options = ThumbnailOptions {
width: 320,
height: 240,
quality: 85,
format: ThumbnailFormat::Jpeg,
};
let serialized = serde_json::to_string(&options).unwrap();
let deserialized: ThumbnailOptions = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.width, 320);
assert_eq!(deserialized.height, 240);
assert_eq!(deserialized.quality, 85);
assert!(matches!(deserialized.format, ThumbnailFormat::Jpeg));
}
#[tokio::test]
async fn test_thumbnail_format_variants() {
for format in [ThumbnailFormat::Jpeg, ThumbnailFormat::Png, ThumbnailFormat::WebP] {
let options = ThumbnailOptions {
width: 100,
height: 100,
quality: 90,
format,
};
let serialized = serde_json::to_string(&options).unwrap();
let deserialized: ThumbnailOptions = serde_json::from_str(&serialized).unwrap();
assert!(matches!(deserialized.format, _));
}
}
#[tokio::test]
async fn test_thumbnail_info_structure() {
let info = ThumbnailInfo {
path: PathBuf::from("/thumbnails/test.jpg"),
width: 320,
height: 240,
file_size_bytes: 45_000,
};
assert_eq!(info.path, PathBuf::from("/thumbnails/test.jpg"));
assert_eq!(info.width, 320);
assert_eq!(info.height, 240);
assert_eq!(info.file_size_bytes, 45_000);
}
#[tokio::test]
async fn test_media_type_definition() {
let media_type = MediaTypeDefinition {
id: "custom-image format".to_string(),
name: "Custom Image Format".to_string(),
category: "image".to_string(),
extensions: vec!["cif".to_string(), "custom-img".to_string()],
mime_types: vec!["image/x-custom".to_string()],
icon: Some("image-cif".to_string()),
};
assert_eq!(media_type.id, "custom-image format");
assert_eq!(media_type.category, "image");
assert_eq!(media_type.extensions.len(), 2);
assert!(media_type.icon.is_some());
}
#[tokio::test]
async fn test_event_type_variants() {
let variants: Vec<EventType> = vec![
EventType::MediaImported,
EventType::MediaUpdated,
EventType::MediaDeleted,
EventType::MediaTagged,
EventType::MediaUntagged,
EventType::CollectionCreated,
EventType::CollectionUpdated,
EventType::CollectionDeleted,
EventType::ScanStarted,
EventType::ScanCompleted,
EventType::Custom("custom".to_string()),
];
for event_type in &variants {
let serialized = serde_json::to_string(event_type).unwrap();
let _deserialized: EventType = serde_json::from_str(&serialized).unwrap();
}
}
#[tokio::test]
async fn test_event_serialization() {
let event = Event {
event_type: EventType::MediaImported,
timestamp: "2024-01-15T10:00:00Z".to_string(),
data: HashMap::from([
("path".to_string(), serde_json::json!("/media/test.jpg")),
("size".to_string(), serde_json::json!(1024)),
]),
};
let serialized = serde_json::to_string(&event).unwrap();
let deserialized: Event = serde_json::from_str(&serialized).unwrap();
assert!(matches!(deserialized.event_type, EventType::MediaImported));
assert_eq!(deserialized.timestamp, "2024-01-15T10:00:00Z");
}
#[tokio::test]
async fn test_http_request_serialization() {
let request = HttpRequest {
method: "GET".to_string(),
url: "https://api.example.com/data".to_string(),
headers: HashMap::from([
("Authorization".to_string(), "Bearer token".to_string()),
("Content-Type".to_string(), "application/json".to_string()),
]),
body: None,
};
let serialized = serde_json::to_string(&request).unwrap();
let deserialized: HttpRequest = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.method, "GET");
assert_eq!(deserialized.url, "https://api.example.com/data");
assert!(deserialized.body.is_none());
}
#[tokio::test]
async fn test_http_response_serialization() {
let response = HttpResponse {
status: 200,
headers: HashMap::from([("Content-Type".to_string(), "application/json".to_string())]),
body: b"{\"success\": true}".to_vec(),
};
let serialized = serde_json::to_string(&response).unwrap();
let deserialized: HttpResponse = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.status, 200);
assert_eq!(deserialized.body, b"{\"success\": true}");
}
#[tokio::test]
async fn test_log_message_serialization() {
let message = LogMessage {
level: LogLevel::Info,
target: "plugin::metadata".to_string(),
message: "Metadata extraction complete".to_string(),
fields: HashMap::from([
("file_count".to_string(), "42".to_string()),
("duration_ms".to_string(), "150".to_string()),
]),
};
let serialized = serde_json::to_string(&message).unwrap();
let deserialized: LogMessage = serde_json::from_str(&serialized).unwrap();
assert!(matches!(deserialized.level, LogLevel::Info));
assert_eq!(deserialized.target, "plugin::metadata");
assert_eq!(deserialized.message, "Metadata extraction complete");
}
#[tokio::test]
async fn test_log_level_variants() {
let levels = [LogLevel::Trace, LogLevel::Debug, LogLevel::Info, LogLevel::Warn, LogLevel::Error];
for level in levels {
let serialized = serde_json::to_string(&level).unwrap();
let _deserialized: LogLevel = serde_json::from_str(&serialized).unwrap();
}
}
#[tokio::test]
async fn test_plugin_error_variants() {
let errors: Vec<PluginError> = vec![
PluginError::InitializationFailed("WASM load failed".to_string()),
PluginError::UnsupportedOperation("Custom search not implemented".to_string()),
PluginError::InvalidInput("Invalid file path".to_string()),
PluginError::IoError("File not found".to_string()),
PluginError::MetadataExtractionFailed("Parse error".to_string()),
PluginError::ThumbnailGenerationFailed("Format not supported".to_string()),
PluginError::SearchBackendError("Index corrupted".to_string()),
PluginError::PermissionDenied("Access denied to /data".to_string()),
PluginError::ResourceLimitExceeded("Memory limit exceeded".to_string()),
PluginError::Other("Unknown error".to_string()),
];
for error in errors {
let serialized = serde_json::to_string(&error).unwrap();
let deserialized: PluginError = serde_json::from_str(&serialized).unwrap();
assert_eq!(format!("{}", error), format!("{}", deserialized));
}
}
#[tokio::test]
async fn test_search_index_item_serialization() {
let item = SearchIndexItem {
id: "media-456".to_string(),
title: Some("Summer Vacation".to_string()),
description: Some("Photos from summer vacation 2023".to_string()),
content: None,
tags: vec!["vacation".to_string(), "summer".to_string(), "photos".to_string()],
media_type: "image/jpeg".to_string(),
metadata: HashMap::from([
("camera".to_string(), serde_json::json!("Canon EOS R5")),
("location".to_string(), serde_json::json!("Beach")),
]),
};
let serialized = serde_json::to_string(&item).unwrap();
let deserialized: SearchIndexItem = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.id, "media-456");
assert_eq!(deserialized.title, Some("Summer Vacation".to_string()));
assert_eq!(deserialized.tags.len(), 3);
assert_eq!(deserialized.media_type, "image/jpeg");
}
#[tokio::test]
async fn test_health_status_variants() {
let healthy = HealthStatus {
healthy: true,
message: Some("All systems operational".to_string()),
metrics: HashMap::from([
("items_processed".to_string(), 1000.0),
("avg_process_time_ms".to_string(), 45.5),
]),
};
assert!(healthy.healthy);
let unhealthy = HealthStatus {
healthy: false,
message: Some("Database connection failed".to_string()),
metrics: HashMap::new(),
};
assert!(!unhealthy.healthy);
assert_eq!(unhealthy.message, Some("Database connection failed".to_string()));
}
#[tokio::test]
async fn test_capabilities_merge() {
let mut caps = Capabilities::default();
assert!(!caps.network.enabled);
caps.network.enabled = true;
caps.max_memory_bytes = Some(512 * 1024 * 1024);
assert!(caps.network.enabled);
assert_eq!(caps.max_memory_bytes, Some(512 * 1024 * 1024));
}
#[tokio::test]
async fn test_filesystem_capability_paths() {
let caps = FilesystemCapability {
read: vec![
PathBuf::from("/data"),
PathBuf::from("/media"),
PathBuf::from("/home/user/uploads"),
],
write: vec![PathBuf::from("/tmp/pinakes")],
};
assert_eq!(caps.read.len(), 3);
assert_eq!(caps.write.len(), 1);
assert!(caps.read.contains(&PathBuf::from("/data")));
assert!(caps.write.contains(&PathBuf::from("/tmp/pinakes")));
}
#[tokio::test]
async fn test_plugin_metadata_structure() {
let metadata = PluginMetadata {
id: "test-plugin".to_string(),
name: "Test Plugin".to_string(),
version: "1.0.0".to_string(),
author: "Test Author".to_string(),
description: "A test plugin for unit testing".to_string(),
api_version: "1.0".to_string(),
capabilities_required: Capabilities::default(),
};
assert_eq!(metadata.id, "test-plugin");
assert_eq!(metadata.version, "1.0.0");
assert_eq!(metadata.api_version, "1.0");
}
#[tokio::test]
async fn test_network_capability_defaults() {
let network = NetworkCapability::default();
assert!(!network.enabled);
assert!(network.allowed_domains.is_none());
}
#[tokio::test]
async fn test_environment_capability_defaults() {
let env = EnvironmentCapability::default();
assert!(!env.enabled);
assert!(env.allowed_vars.is_none());
}
#[tokio::test]
async fn test_extracted_metadata_default() {
let metadata = ExtractedMetadata::default();
assert!(metadata.title.is_none());
assert!(metadata.description.is_none());
assert!(metadata.author.is_none());
assert!(metadata.duration_secs.is_none());
assert!(metadata.width.is_none());
assert!(metadata.height.is_none());
assert!(metadata.custom_fields.is_empty());
assert!(metadata.tags.is_empty());
}
#[tokio::test]
async fn test_search_query_structure() {
let query = SearchQuery {
query_text: "test query".to_string(),
filters: HashMap::new(),
limit: 10,
offset: 0,
};
assert!(!query.query_text.is_empty());
assert_eq!(query.limit, 10);
assert_eq!(query.offset, 0);
}
#[tokio::test]
async fn test_thumbnail_options_structure() {
let options = ThumbnailOptions {
width: 640,
height: 480,
quality: 75,
format: ThumbnailFormat::Png,
};
assert_eq!(options.width, 640);
assert_eq!(options.height, 480);
assert_eq!(options.quality, 75);
assert!(matches!(options.format, ThumbnailFormat::Png));
}

View file

@ -2,31 +2,6 @@
name = "heif-support"
version = "1.0.0"
api_version = "1.0"
author = "Pinakes Team"
description = "HEIF/HEIC image format support with metadata extraction and thumbnail generation"
homepage = "https://github.com/pinakes/pinakes"
license = "MIT OR Apache-2.0"
kind = ["media_type", "metadata_extractor", "thumbnail_generator"]
[plugin.binary]
wasm = "heif_support.wasm"
[capabilities]
max_memory_mb = 256
max_cpu_time_secs = 30
[capabilities.filesystem]
read = ["/tmp/pinakes-input"]
write = ["/tmp/pinakes-output"]
[config]
extract_exif = { type = "boolean", default = true, description = "Extract EXIF metadata from HEIF images" }
generate_thumbnails = { type = "boolean", default = true, description = "Generate thumbnails for HEIF images" }
thumbnail_quality = { type = "integer", default = 85, description = "JPEG quality for thumbnails (1-100)" }
[plugin]
name = "heif-support"
version = "1.0.0"
api_version = "1.0"
author = "Pinakes Contributors"
description = "HEIF/HEIC image format support for Pinakes"
homepage = "https://github.com/notashelf/pinakes"
@ -42,21 +17,13 @@ max_memory_mb = 256
max_cpu_time_secs = 30
[capabilities.filesystem]
# Read access for processing images (use specific paths in production)
read = ["/media"]
# Write access for thumbnail generation
write = ["/tmp/pinakes"]
# Plugin configuration
[config]
# Enable EXIF metadata extraction
extract_exif = true
# Enable thumbnail generation
generate_thumbnails = true
# Thumbnail quality (1-100)
thumbnail_quality = 85
# Thumbnail format (jpeg, png, webp)
thumbnail_format = "jpeg"
# Maximum image dimensions to process
max_width = 8192
max_height = 8192

View file

@ -2,29 +2,8 @@
name = "markdown-metadata"
version = "1.0.0"
api_version = "1.0"
author = "Pinakes Team"
description = "Extract metadata from Markdown files with YAML frontmatter"
homepage = "https://github.com/pinakes/pinakes"
license = "MIT OR Apache-2.0"
kind = ["metadata_extractor"]
[plugin.binary]
wasm = "markdown_metadata.wasm"
[capabilities]
# No filesystem or network access needed
# Plugin operates on provided content
[config]
extract_tags = { type = "boolean", default = true, description = "Extract tags from YAML frontmatter" }
parse_yaml = { type = "boolean", default = true, description = "Parse YAML frontmatter" }
max_file_size = { type = "integer", default = 10485760, description = "Maximum file size in bytes (10MB)" }
[plugin]
name = "markdown-metadata"
version = "1.0.0"
api_version = "1.0"
author = "Pinakes Contributors"
description = "Enhanced Markdown metadata extractor with frontmatter parsing"
description = "Extract metadata from Markdown files with YAML frontmatter"
homepage = "https://github.com/notashelf/pinakes"
license = "MIT"
kind = ["metadata_extractor"]
@ -39,13 +18,8 @@ network = false
read = []
write = []
# Plugin configuration
[config]
# Extract frontmatter tags as media tags
extract_tags = true
# Parse YAML frontmatter
parse_yaml = true
# Parse TOML frontmatter
parse_toml = true
# Maximum file size to process (in bytes)
max_file_size = 10485760 # 10MB
max_file_size = 10485760