Skip to main content

max / balanced_breakfast

26.9 KB · 831 lines History Blame Raw
1 //! Feed management commands (create, delete, fetch)
2 use crate::commands::error::ApiError;
3 use crate::state::AppState;
4 use bb_db::FeedId;
5 use futures::stream::{self, StreamExt};
6 use serde::{Deserialize, Serialize};
7 use sqlx::Acquire;
8 use std::sync::atomic::{AtomicUsize, Ordering};
9 use std::sync::Arc;
10 use tauri::{Emitter, State};
11 use tracing::{info, instrument};
12
13 /// Frontend input for creating a new feed subscription.
14 #[derive(Debug, Clone, Deserialize)]
15 #[serde(rename_all = "camelCase")]
16 pub struct CreateFeedInput {
17 pub busser_id: String,
18 pub name: String,
19 pub config: serde_json::Value,
20 }
21
22 /// Serializable snapshot of a feed subscription (for undo/restore).
23 #[derive(Debug, Clone, Serialize)]
24 #[serde(rename_all = "camelCase")]
25 pub struct FeedSnapshot {
26 pub busser_id: String,
27 pub name: String,
28 pub config: serde_json::Value,
29 }
30
31 /// Response returned after a bulk fetch operation.
32 #[derive(Debug, Clone, Serialize)]
33 #[serde(rename_all = "camelCase")]
34 pub struct FetchResponse {
35 pub items_fetched: usize,
36 #[serde(skip_serializing_if = "Vec::is_empty")]
37 pub errors: Vec<String>,
38 }
39
40 /// Progress event emitted per-plugin during fetch.
41 #[derive(Debug, Clone, Serialize)]
42 #[serde(rename_all = "camelCase")]
43 struct FetchProgress {
44 completed: usize,
45 total: usize,
46 plugin_id: String,
47 }
48
49 /// Get all feed subscriptions for a busser, including their config.
50 ///
51 /// Used by the frontend to snapshot feed details before deletion (for undo).
52 #[tauri::command]
53 #[instrument(skip_all)]
54 pub async fn get_feeds_by_busser(
55 state: State<'_, Arc<AppState>>,
56 busser_id: String,
57 ) -> Result<Vec<FeedSnapshot>, ApiError> {
58 let db = state.orchestrator.database();
59 let feeds = db.feeds().get_by_busser(&busser_id).await?;
60
61 // Decrypt Secret fields before returning to the frontend
62 let key = state.orchestrator.encryption_key();
63 let schema = {
64 let plugins = state.orchestrator.plugins();
65 let plugins = plugins.read().await;
66 plugins.get_config_schema(&busser_id)
67 };
68
69 Ok(feeds
70 .into_iter()
71 .map(|f| {
72 let mut config = f.config_json();
73 if let (Some(k), Some(s)) = (key, &schema) {
74 bb_core::crypto::decrypt_config_secrets(&mut config, s, k);
75 }
76 FeedSnapshot {
77 busser_id: f.busser_id.to_string(),
78 name: f.name,
79 config,
80 }
81 })
82 .collect())
83 }
84
85 /// Validate feed creation input against the plugin's config schema.
86 ///
87 /// Returns the trimmed feed name on success.
88 fn validate_feed_input(
89 name: &str,
90 config: &serde_json::Value,
91 schema: &bb_interface::ConfigSchema,
92 ) -> Result<String, ApiError> {
93 let name = name.trim().to_string();
94 if name.is_empty() {
95 return Err(ApiError::bad_request("Feed name cannot be empty"));
96 }
97 if name.len() > 200 {
98 return Err(ApiError::bad_request(
99 "Feed name cannot exceed 200 characters",
100 ));
101 }
102
103 let config_obj = config
104 .as_object()
105 .ok_or_else(|| ApiError::bad_request("Config must be a JSON object"))?;
106
107 for field in &schema.fields {
108 let value = config_obj.get(&field.key);
109 if field.required && value.is_none_or(|v| v.as_str().is_some_and(|s| s.is_empty())) {
110 return Err(ApiError::bad_request(format!(
111 "Missing required field: {}",
112 field.label
113 )));
114 }
115
116 if let Some(serde_json::Value::String(s)) = value {
117 if !s.is_empty() {
118 match field.field_type {
119 bb_interface::ConfigFieldType::Url => {
120 if !s.starts_with("http://") && !s.starts_with("https://") {
121 return Err(ApiError::bad_request(format!(
122 "'{}' must start with http:// or https://",
123 field.label
124 )));
125 }
126 let after_scheme = s.split("://").nth(1).unwrap_or("");
127 if after_scheme.is_empty() || after_scheme == "/" {
128 return Err(ApiError::bad_request(format!(
129 "'{}' is not a valid URL",
130 field.label
131 )));
132 }
133 }
134 bb_interface::ConfigFieldType::Number => {
135 if s.parse::<f64>().is_err() {
136 return Err(ApiError::bad_request(format!(
137 "'{}' must be a valid number",
138 field.label
139 )));
140 }
141 }
142 bb_interface::ConfigFieldType::Select => {
143 if !field.options.contains(s) {
144 return Err(ApiError::bad_request(format!(
145 "'{}' must be one of: {}",
146 field.label,
147 field.options.join(", ")
148 )));
149 }
150 }
151 bb_interface::ConfigFieldType::Toggle => {
152 if s != "true" && s != "false" {
153 return Err(ApiError::bad_request(format!(
154 "'{}' must be \"true\" or \"false\"",
155 field.label
156 )));
157 }
158 }
159 bb_interface::ConfigFieldType::Text
160 | bb_interface::ConfigFieldType::TextArea
161 | bb_interface::ConfigFieldType::Secret => {
162 if s.len() > 10_000 {
163 return Err(ApiError::bad_request(format!(
164 "'{}' exceeds maximum length of 10,000 characters",
165 field.label
166 )));
167 }
168 }
169 }
170 }
171 }
172 }
173
174 Ok(name)
175 }
176
177 /// Create a new feed and re-initialize its plugin.
178 #[tauri::command]
179 #[instrument(skip_all)]
180 pub async fn create_feed(
181 state: State<'_, Arc<AppState>>,
182 input: CreateFeedInput,
183 ) -> Result<(), ApiError> {
184 // Validate busser exists and get its schema
185 let schema = {
186 let plugins = state.orchestrator.plugins();
187 let plugins = plugins.read().await;
188 plugins
189 .get_config_schema(&input.busser_id)
190 .ok_or_else(|| {
191 ApiError::not_found(format!("Plugin '{}' not found", input.busser_id))
192 })?
193 };
194
195 let name = validate_feed_input(&input.name, &input.config, &schema)?;
196
197 // Serialize the plaintext config for the duplicate check before we move
198 // `input.config` into the encryption step (avoids an extra clone).
199 let new_config_str = serde_json::to_string(&input.config).unwrap_or_default();
200
201 let db = state.orchestrator.database();
202
203 // Encrypt Secret-type fields before storage (mutates in place, no clone)
204 let config = {
205 let mut cfg = input.config;
206 if let Some(key) = state.orchestrator.encryption_key() {
207 let plugins = state.orchestrator.plugins();
208 let plugins = plugins.read().await;
209 if let Some(schema) = plugins.get_config_schema(&input.busser_id) {
210 bb_core::crypto::encrypt_config_secrets(&mut cfg, &schema, key);
211 }
212 }
213 cfg
214 };
215
216 // Check for duplicate config and insert inside a single transaction to
217 // avoid a TOCTOU race (two concurrent create_feed calls with the same
218 // config could both pass the duplicate check before either inserts).
219 let mut conn = db.pool().acquire().await?;
220 let mut tx = conn.begin().await?;
221
222 // Duplicate-config check (inside the transaction)
223 {
224 let existing: Vec<bb_db::DbFeed> =
225 sqlx::query_as("SELECT * FROM feeds WHERE busser_id = ?1 ORDER BY name")
226 .bind(&input.busser_id)
227 .fetch_all(&mut *tx)
228 .await?;
229
230 let key = state.orchestrator.encryption_key();
231 let schema = {
232 let plugins = state.orchestrator.plugins();
233 let plugins = plugins.read().await;
234 plugins.get_config_schema(&input.busser_id)
235 };
236
237 for feed in &existing {
238 let mut existing_config = feed.config_json();
239 // Decrypt existing config for comparison against plaintext input
240 if let (Some(k), Some(s)) = (key, &schema) {
241 bb_core::crypto::decrypt_config_secrets(&mut existing_config, s, k);
242 }
243 let existing_config_str =
244 serde_json::to_string(&existing_config).unwrap_or_default();
245 if existing_config_str == new_config_str {
246 return Err(ApiError::bad_request(
247 "A feed with this configuration already exists",
248 ));
249 }
250 }
251 }
252
253 // Insert the new feed (still inside the transaction)
254 let feed_id = bb_db::FeedId::new();
255 let now = chrono::Utc::now()
256 .format(bb_db::TIMESTAMP_FMT)
257 .to_string();
258 let config_str =
259 serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string());
260
261 sqlx::query(
262 "INSERT INTO feeds (id, busser_id, name, config, enabled, created_at, updated_at) \
263 VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5)",
264 )
265 .bind(feed_id)
266 .bind(&input.busser_id)
267 .bind(&name)
268 .bind(&config_str)
269 .bind(&now)
270 .execute(&mut *tx)
271 .await?;
272
273 tx.commit().await?;
274
275 // Re-initialize the plugin with the new feed
276 if let Err(e) = state
277 .orchestrator
278 .init_plugin_from_db(&input.busser_id)
279 .await
280 {
281 tracing::warn!(error = %e, busser_id = %input.busser_id, "Failed to reinitialize plugin");
282 }
283
284 info!(busser_id = %input.busser_id, "Created new feed");
285 Ok(())
286 }
287
288 /// Response for a single feed with its database ID (for editing).
289 #[derive(Debug, Clone, Serialize)]
290 #[serde(rename_all = "camelCase")]
291 pub struct FeedResponse {
292 pub id: String,
293 pub busser_id: String,
294 pub name: String,
295 pub config: serde_json::Value,
296 }
297
298 /// Get the first feed for a busser, with decrypted config for editing.
299 #[tauri::command]
300 #[instrument(skip_all)]
301 pub async fn get_feed(
302 state: State<'_, Arc<AppState>>,
303 busser_id: String,
304 ) -> Result<FeedResponse, ApiError> {
305 let db = state.orchestrator.database();
306 let feeds = db.feeds().get_by_busser(&busser_id).await?;
307 let feed = feeds.into_iter().next().ok_or_else(|| ApiError::not_found("Feed not found"))?;
308
309 let key = state.orchestrator.encryption_key();
310 let schema = {
311 let plugins = state.orchestrator.plugins();
312 let plugins = plugins.read().await;
313 plugins.get_config_schema(&busser_id)
314 };
315
316 let mut config = feed.config_json();
317 if let (Some(k), Some(s)) = (key, &schema) {
318 bb_core::crypto::decrypt_config_secrets(&mut config, s, k);
319 }
320
321 Ok(FeedResponse {
322 id: feed.id.to_string(),
323 busser_id: feed.busser_id.to_string(),
324 name: feed.name,
325 config,
326 })
327 }
328
329 /// Update an existing feed's name and config.
330 #[tauri::command]
331 #[instrument(skip_all)]
332 pub async fn update_feed(
333 state: State<'_, Arc<AppState>>,
334 id: String,
335 name: String,
336 config: serde_json::Value,
337 ) -> Result<(), ApiError> {
338 let feed_id: FeedId = id.parse().map_err(|_| ApiError::bad_request("Invalid feed ID"))?;
339 let db = state.orchestrator.database();
340
341 // Load existing feed to get busser_id
342 let feed = db.feeds().get(feed_id).await?.ok_or_else(|| ApiError::not_found("Feed not found"))?;
343 let busser_id = feed.busser_id.to_string();
344
345 // Validate against plugin schema
346 let schema = {
347 let plugins = state.orchestrator.plugins();
348 let plugins = plugins.read().await;
349 plugins.get_config_schema(&busser_id).ok_or_else(|| {
350 ApiError::not_found(format!("Plugin '{}' not found", busser_id))
351 })?
352 };
353
354 let name = validate_feed_input(&name, &config, &schema)?;
355
356 // Encrypt Secret-type fields before storage (mutate in place, no clone)
357 let mut config = config;
358 if let Some(key) = state.orchestrator.encryption_key() {
359 let plugins = state.orchestrator.plugins();
360 let plugins = plugins.read().await;
361 if let Some(schema) = plugins.get_config_schema(&busser_id) {
362 bb_core::crypto::encrypt_config_secrets(&mut config, &schema, key);
363 }
364 }
365
366 // Update name and config
367 db.feeds().update_name(feed_id, &name).await?;
368 let config_str = serde_json::to_string(&config)
369 .map_err(|e| ApiError::internal(format!("Failed to serialize config: {}", e)))?;
370 db.feeds().update_config(feed_id, &config_str).await?;
371
372 // Re-initialize the plugin with the updated config
373 if let Err(e) = state.orchestrator.init_plugin_from_db(&busser_id).await {
374 tracing::warn!(error = %e, %busser_id, "Failed to reinitialize plugin");
375 }
376
377 info!(%id, %busser_id, "Updated feed");
378 Ok(())
379 }
380
381 /// Delete a single feed and all its items in a transaction.
382 #[tauri::command]
383 #[instrument(skip_all)]
384 pub async fn delete_feed(
385 state: State<'_, Arc<AppState>>,
386 id: String,
387 ) -> Result<(), ApiError> {
388 let feed_id: FeedId = id.parse().map_err(|_| ApiError::bad_request("Invalid feed ID"))?;
389 let db = state.orchestrator.database();
390
391 // Delete items and feed in a single transaction
392 let mut conn = db.pool().acquire().await?;
393 let mut tx = conn.begin().await?;
394
395 sqlx::query("DELETE FROM feed_items WHERE feed_id = ?1")
396 .bind(feed_id)
397 .execute(&mut *tx)
398 .await?;
399
400 sqlx::query("DELETE FROM feeds WHERE id = ?1")
401 .bind(feed_id)
402 .execute(&mut *tx)
403 .await?;
404
405 tx.commit().await?;
406
407 info!(%id, "Deleted feed");
408 Ok(())
409 }
410
411 /// Delete all feeds (and their items) belonging to a busser.
412 #[tauri::command]
413 #[instrument(skip_all)]
414 pub async fn delete_feeds_by_busser(
415 state: State<'_, Arc<AppState>>,
416 busser_id: String,
417 ) -> Result<(), ApiError> {
418 let db = state.orchestrator.database();
419
420 // Get all feeds for this busser so we can delete their items
421 let feeds = db.feeds().get_by_busser(&busser_id).await?;
422
423 let mut conn = db.pool().acquire().await?;
424 let mut tx = conn.begin().await?;
425
426 for feed in &feeds {
427 sqlx::query("DELETE FROM feed_items WHERE feed_id = ?1")
428 .bind(feed.id)
429 .execute(&mut *tx)
430 .await?;
431
432 sqlx::query("DELETE FROM feeds WHERE id = ?1")
433 .bind(feed.id)
434 .execute(&mut *tx)
435 .await?;
436 }
437
438 tx.commit().await?;
439
440 info!(count = feeds.len(), %busser_id, "Deleted feeds");
441 Ok(())
442 }
443
444 /// Tag rules for Balanced Breakfast: shallow hierarchy, no required semantic prefix.
445 const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
446 max_depth: 3,
447 max_length: 80,
448 semantic_depth: 0,
449 };
450
451 /// Set tags on all feeds belonging to a busser.
452 #[tauri::command]
453 #[instrument(skip_all)]
454 pub async fn set_feed_tags(
455 state: State<'_, Arc<AppState>>,
456 busser_id: String,
457 tags: Vec<String>,
458 ) -> Result<(), ApiError> {
459 for tag in &tags {
460 tagtree::validate_with(tag, &BB_TAG_CONFIG)
461 .map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?;
462 }
463
464 let db = state.orchestrator.database();
465 let feeds = db.feeds().get_by_busser(&busser_id).await?;
466
467 for feed in &feeds {
468 db.tags().set_tags(feed.id, &tags).await?;
469 }
470
471 info!(?tags, count = feeds.len(), %busser_id, "Set feed tags");
472 Ok(())
473 }
474
475 /// List all distinct tags across all feeds.
476 #[tauri::command]
477 #[instrument(skip_all)]
478 pub async fn list_all_tags(
479 state: State<'_, Arc<AppState>>,
480 ) -> Result<Vec<String>, ApiError> {
481 Ok(state.orchestrator.database().tags().list_all_tags().await?)
482 }
483
484 /// Trigger a fetch from all active plugins and return the total item count.
485 /// Emits `fetch-progress` events per plugin so the frontend can show a real progress bar.
486 #[tauri::command]
487 #[instrument(skip_all)]
488 pub async fn fetch_all(
489 app: tauri::AppHandle,
490 state: State<'_, Arc<AppState>>,
491 ) -> Result<FetchResponse, ApiError> {
492 let plugin_ids = {
493 let plugins = state.orchestrator.plugins();
494 let plugins = plugins.read().await;
495 plugins.list_plugins()
496 };
497
498 let total = plugin_ids.len();
499 let completed = AtomicUsize::new(0);
500
501 let results: Vec<_> = stream::iter(plugin_ids)
502 .map(|plugin_id| {
503 let app = &app;
504 let orchestrator = &state.orchestrator;
505 let completed = &completed;
506 async move {
507 let result = orchestrator.fetch_plugin(&plugin_id).await;
508 let n = completed.fetch_add(1, Ordering::Relaxed) + 1;
509 let _ = app.emit("fetch-progress", FetchProgress {
510 completed: n,
511 total,
512 plugin_id: plugin_id.clone(),
513 });
514 (plugin_id, result)
515 }
516 })
517 .buffer_unordered(4)
518 .collect()
519 .await;
520
521 let mut items_fetched = 0;
522 let mut errors = Vec::new();
523 for (plugin_id, result) in results {
524 match result {
525 Ok(count) => items_fetched += count,
526 Err(e) => {
527 tracing::error!(error = %e, %plugin_id, "Failed to fetch from plugin");
528 errors.push(format!("{}: {}", plugin_id, e));
529 }
530 }
531 }
532
533 info!(items_fetched, errors = errors.len(), "Fetched from all sources");
534 Ok(FetchResponse {
535 items_fetched,
536 errors,
537 })
538 }
539
540 /// Reset the circuit breaker for a feed and attempt a fresh fetch.
541 ///
542 /// When a feed has been auto-disabled after too many consecutive failures,
543 /// this command clears the circuit-broken state, resets the failure counter,
544 /// and immediately retries the fetch. Returns the number of items fetched.
545 #[tauri::command]
546 #[instrument(skip_all)]
547 pub async fn reset_circuit_breaker(
548 state: State<'_, Arc<AppState>>,
549 busser_id: String,
550 ) -> Result<FetchResponse, ApiError> {
551 let count = state
552 .orchestrator
553 .reset_circuit_breaker_and_fetch(&busser_id)
554 .await?;
555
556 info!(%busser_id, items_fetched = count, "Circuit breaker reset");
557 Ok(FetchResponse {
558 items_fetched: count,
559 errors: Vec::new(),
560 })
561 }
562
563 #[cfg(test)]
564 mod tests {
565 use super::*;
566 use bb_interface::{ConfigField, ConfigFieldType, ConfigSchema};
567
568 /// Build a schema with a single required URL field (typical RSS feed config).
569 fn rss_schema() -> ConfigSchema {
570 ConfigSchema {
571 description: "RSS feed".to_string(),
572 fields: vec![ConfigField {
573 key: "feed_url".to_string(),
574 label: "Feed URL".to_string(),
575 description: None,
576 field_type: ConfigFieldType::Url,
577 required: true,
578 default: None,
579 options: vec![],
580 placeholder: None,
581 }],
582 }
583 }
584
585 /// Build an empty schema (no config fields required).
586 fn empty_schema() -> ConfigSchema {
587 ConfigSchema {
588 description: "No config".to_string(),
589 fields: vec![],
590 }
591 }
592
593 /// Build a schema with one field of the given type.
594 fn schema_with_field(
595 key: &str,
596 label: &str,
597 field_type: ConfigFieldType,
598 required: bool,
599 options: Vec<String>,
600 ) -> ConfigSchema {
601 ConfigSchema {
602 description: "Test".to_string(),
603 fields: vec![ConfigField {
604 key: key.to_string(),
605 label: label.to_string(),
606 description: None,
607 field_type,
608 required,
609 default: None,
610 options,
611 placeholder: None,
612 }],
613 }
614 }
615
616 // -- Name validation --
617
618 #[test]
619 fn validate_empty_name() {
620 let r = validate_feed_input("", &serde_json::json!({}), &empty_schema());
621 assert!(r.is_err());
622 assert!(r.unwrap_err().message.contains("empty"));
623 }
624
625 #[test]
626 fn validate_whitespace_only_name() {
627 let r = validate_feed_input(" ", &serde_json::json!({}), &empty_schema());
628 assert!(r.is_err());
629 assert!(r.unwrap_err().message.contains("empty"));
630 }
631
632 #[test]
633 fn validate_name_too_long() {
634 let long = "x".repeat(201);
635 let r = validate_feed_input(&long, &serde_json::json!({}), &empty_schema());
636 assert!(r.is_err());
637 assert!(r.unwrap_err().message.contains("200"));
638 }
639
640 #[test]
641 fn validate_name_at_limit() {
642 let name = "x".repeat(200);
643 let r = validate_feed_input(&name, &serde_json::json!({}), &empty_schema());
644 assert!(r.is_ok());
645 assert_eq!(r.unwrap(), name);
646 }
647
648 #[test]
649 fn validate_name_trimmed() {
650 let r = validate_feed_input(" My Feed ", &serde_json::json!({}), &empty_schema());
651 assert_eq!(r.unwrap(), "My Feed");
652 }
653
654 // -- Config structure --
655
656 #[test]
657 fn validate_config_not_object() {
658 let r = validate_feed_input("Feed", &serde_json::json!("string"), &empty_schema());
659 assert!(r.is_err());
660 assert!(r.unwrap_err().message.contains("JSON object"));
661 }
662
663 #[test]
664 fn validate_config_array_rejected() {
665 let r = validate_feed_input("Feed", &serde_json::json!([1, 2]), &empty_schema());
666 assert!(r.is_err());
667 }
668
669 // -- Required fields --
670
671 #[test]
672 fn validate_missing_required_field() {
673 let r = validate_feed_input("Feed", &serde_json::json!({}), &rss_schema());
674 assert!(r.is_err());
675 assert!(r.unwrap_err().message.contains("Feed URL"));
676 }
677
678 #[test]
679 fn validate_empty_required_field() {
680 let config = serde_json::json!({"feed_url": ""});
681 let r = validate_feed_input("Feed", &config, &rss_schema());
682 assert!(r.is_err());
683 assert!(r.unwrap_err().message.contains("Feed URL"));
684 }
685
686 #[test]
687 fn validate_optional_field_missing_ok() {
688 let schema = schema_with_field("notes", "Notes", ConfigFieldType::Text, false, vec![]);
689 let r = validate_feed_input("Feed", &serde_json::json!({}), &schema);
690 assert!(r.is_ok());
691 }
692
693 // -- URL validation --
694
695 #[test]
696 fn validate_url_valid() {
697 let config = serde_json::json!({"feed_url": "https://example.com/feed.xml"});
698 assert!(validate_feed_input("Feed", &config, &rss_schema()).is_ok());
699 }
700
701 #[test]
702 fn validate_url_http_valid() {
703 let config = serde_json::json!({"feed_url": "http://example.com/feed"});
704 assert!(validate_feed_input("Feed", &config, &rss_schema()).is_ok());
705 }
706
707 #[test]
708 fn validate_url_missing_scheme() {
709 let config = serde_json::json!({"feed_url": "example.com/feed"});
710 let r = validate_feed_input("Feed", &config, &rss_schema());
711 assert!(r.unwrap_err().message.contains("http"));
712 }
713
714 #[test]
715 fn validate_url_empty_after_scheme() {
716 let config = serde_json::json!({"feed_url": "https://"});
717 let r = validate_feed_input("Feed", &config, &rss_schema());
718 assert!(r.is_err());
719 assert!(r.unwrap_err().message.contains("valid URL"));
720 }
721
722 #[test]
723 fn validate_url_only_slash_after_scheme() {
724 let config = serde_json::json!({"feed_url": "https:///"});
725 let r = validate_feed_input("Feed", &config, &rss_schema());
726 assert!(r.is_err());
727 }
728
729 // -- Number validation --
730
731 #[test]
732 fn validate_number_valid() {
733 let schema = schema_with_field("count", "Count", ConfigFieldType::Number, true, vec![]);
734 let config = serde_json::json!({"count": "42"});
735 assert!(validate_feed_input("Feed", &config, &schema).is_ok());
736 }
737
738 #[test]
739 fn validate_number_float_valid() {
740 let schema = schema_with_field("count", "Count", ConfigFieldType::Number, true, vec![]);
741 let config = serde_json::json!({"count": "3.14"});
742 assert!(validate_feed_input("Feed", &config, &schema).is_ok());
743 }
744
745 #[test]
746 fn validate_number_invalid() {
747 let schema = schema_with_field("count", "Count", ConfigFieldType::Number, true, vec![]);
748 let config = serde_json::json!({"count": "abc"});
749 let r = validate_feed_input("Feed", &config, &schema);
750 assert!(r.unwrap_err().message.contains("valid number"));
751 }
752
753 // -- Select validation --
754
755 #[test]
756 fn validate_select_valid() {
757 let schema = schema_with_field(
758 "sort",
759 "Sort",
760 ConfigFieldType::Select,
761 true,
762 vec!["new".into(), "top".into()],
763 );
764 let config = serde_json::json!({"sort": "new"});
765 assert!(validate_feed_input("Feed", &config, &schema).is_ok());
766 }
767
768 #[test]
769 fn validate_select_invalid_option() {
770 let schema = schema_with_field(
771 "sort",
772 "Sort",
773 ConfigFieldType::Select,
774 true,
775 vec!["new".into(), "top".into()],
776 );
777 let config = serde_json::json!({"sort": "random"});
778 let r = validate_feed_input("Feed", &config, &schema);
779 assert!(r.unwrap_err().message.contains("must be one of"));
780 }
781
782 // -- Toggle validation --
783
784 #[test]
785 fn validate_toggle_valid() {
786 let schema =
787 schema_with_field("enabled", "Enabled", ConfigFieldType::Toggle, true, vec![]);
788 assert!(validate_feed_input("F", &serde_json::json!({"enabled": "true"}), &schema).is_ok());
789 assert!(
790 validate_feed_input("F", &serde_json::json!({"enabled": "false"}), &schema).is_ok()
791 );
792 }
793
794 #[test]
795 fn validate_toggle_invalid() {
796 let schema =
797 schema_with_field("enabled", "Enabled", ConfigFieldType::Toggle, true, vec![]);
798 let config = serde_json::json!({"enabled": "yes"});
799 let r = validate_feed_input("Feed", &config, &schema);
800 assert!(r.unwrap_err().message.contains("true"));
801 }
802
803 // -- Text length validation --
804
805 #[test]
806 fn validate_text_within_limit() {
807 let schema = schema_with_field("bio", "Bio", ConfigFieldType::TextArea, false, vec![]);
808 let config = serde_json::json!({"bio": "Hello world"});
809 assert!(validate_feed_input("Feed", &config, &schema).is_ok());
810 }
811
812 #[test]
813 fn validate_text_exceeds_limit() {
814 let schema = schema_with_field("bio", "Bio", ConfigFieldType::Text, false, vec![]);
815 let long = "x".repeat(10_001);
816 let config = serde_json::json!({"bio": long});
817 let r = validate_feed_input("Feed", &config, &schema);
818 assert!(r.unwrap_err().message.contains("10,000"));
819 }
820
821 #[test]
822 fn validate_secret_exceeds_limit() {
823 let schema =
824 schema_with_field("token", "API Token", ConfigFieldType::Secret, false, vec![]);
825 let long = "x".repeat(10_001);
826 let config = serde_json::json!({"token": long});
827 let r = validate_feed_input("Feed", &config, &schema);
828 assert!(r.is_err());
829 }
830 }
831