Skip to main content

max / makenotwork

24.5 KB · 795 lines History Blame Raw
1 //! Internal API endpoints for CLI feature access (tags, broadcast, tiers,
2 //! collections, custom domains).
3
4 use crate::auth::InternalActor;
5 use axum::Json;
6 use axum::extract::State;
7 use axum::response::IntoResponse;
8 use serde::{Deserialize, Serialize};
9
10 use sqlx::PgPool;
11
12 use crate::AppCaches;
13 use crate::auth::ServiceAuth;
14 use crate::config::Config;
15 use crate::constants;
16 use crate::db::{self, CollectionId, ItemId, ProjectId, Slug};
17 use crate::email::EmailClient;
18 use crate::error::{AppError, Result, ResultExt};
19
20 /// User ID query parameter shared by all internal endpoints.
21 #[derive(Deserialize)]
22 pub(super) struct UserIdParam {}
23
24 // --- Tags ---
25
26 #[derive(Deserialize)]
27 pub(super) struct TagItemRequest {
28 item_id: ItemId,
29 tag_id: String,
30 }
31
32 #[derive(Serialize)]
33 struct TagView {
34 id: String,
35 name: String,
36 slug: String,
37 is_primary: bool,
38 }
39
40 /// GET /api/internal/creator/items/{id}/tags?user_id=...
41 #[tracing::instrument(skip_all, name = "internal::list_item_tags")]
42 pub(super) async fn list_item_tags(
43 State(db): State<PgPool>,
44 actor: InternalActor,
45 _auth: ServiceAuth,
46 axum::extract::Path(item_id): axum::extract::Path<ItemId>,
47 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
48 ) -> Result<impl IntoResponse> {
49 let item = db::items::get_item_by_id(&db, item_id)
50 .await?
51 .ok_or(AppError::NotFound)?;
52 let project = db::projects::get_project_by_id(&db, item.project_id)
53 .await?
54 .ok_or(AppError::NotFound)?;
55 if project.user_id != actor.user_id() {
56 return Err(AppError::Forbidden);
57 }
58
59 let tags = db::tags::get_tags_for_item(&db, item_id).await?;
60 let views: Vec<TagView> = tags
61 .iter()
62 .map(|t| TagView {
63 id: t.tag_id.to_string(),
64 name: t.tag_name.clone(),
65 slug: t.tag_slug.clone(),
66 is_primary: t.is_primary,
67 })
68 .collect();
69
70 Ok(Json(views))
71 }
72
73 /// POST /api/internal/creator/items/tags
74 #[tracing::instrument(skip_all, name = "internal::add_item_tag")]
75 pub(super) async fn add_item_tag(
76 State(db): State<PgPool>,
77 actor: InternalActor,
78 _auth: ServiceAuth,
79 Json(req): Json<TagItemRequest>,
80 ) -> Result<impl IntoResponse> {
81 let item = db::items::get_item_by_id(&db, req.item_id)
82 .await?
83 .ok_or(AppError::NotFound)?;
84 let project = db::projects::get_project_by_id(&db, item.project_id)
85 .await?
86 .ok_or(AppError::NotFound)?;
87 if project.user_id != actor.user_id() {
88 return Err(AppError::Forbidden);
89 }
90
91 let tag_id: db::TagId = req
92 .tag_id
93 .parse::<uuid::Uuid>()
94 .map(db::TagId::from)
95 .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?;
96
97 let _tag = db::tags::get_tag_by_id(&db, tag_id)
98 .await?
99 .ok_or_else(|| AppError::validation("Tag not found".to_string()))?;
100
101 db::tags::add_tag_to_item(&db, req.item_id, tag_id, false).await?;
102
103 Ok(Json(serde_json::json!({"success": true})))
104 }
105
106 /// POST /api/internal/creator/items/tags/remove
107 #[tracing::instrument(skip_all, name = "internal::remove_item_tag")]
108 pub(super) async fn remove_item_tag(
109 State(db): State<PgPool>,
110 actor: InternalActor,
111 _auth: ServiceAuth,
112 Json(req): Json<TagItemRequest>,
113 ) -> Result<impl IntoResponse> {
114 let item = db::items::get_item_by_id(&db, req.item_id)
115 .await?
116 .ok_or(AppError::NotFound)?;
117 let project = db::projects::get_project_by_id(&db, item.project_id)
118 .await?
119 .ok_or(AppError::NotFound)?;
120 if project.user_id != actor.user_id() {
121 return Err(AppError::Forbidden);
122 }
123
124 let tag_id: db::TagId = req
125 .tag_id
126 .parse::<uuid::Uuid>()
127 .map(db::TagId::from)
128 .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?;
129
130 db::tags::remove_tag_from_item(&db, req.item_id, tag_id).await?;
131
132 Ok(Json(serde_json::json!({"success": true})))
133 }
134
135 #[derive(Deserialize)]
136 pub(super) struct TagSearchQuery {
137 q: String,
138 }
139
140 /// GET /api/internal/tags/search?q=...
141 #[tracing::instrument(skip_all, name = "internal::search_tags")]
142 pub(super) async fn search_tags(
143 State(db): State<PgPool>,
144 _auth: ServiceAuth,
145 axum::extract::Query(q): axum::extract::Query<TagSearchQuery>,
146 ) -> Result<impl IntoResponse> {
147 let tags = db::tags::search_tags(&db, &q.q, 20).await?;
148 let views: Vec<TagView> = tags
149 .iter()
150 .map(|t| TagView {
151 id: t.id.to_string(),
152 name: t.name.clone(),
153 slug: t.slug.clone(),
154 is_primary: false,
155 })
156 .collect();
157 Ok(Json(views))
158 }
159
160 // --- Broadcast ---
161
162 #[derive(Deserialize)]
163 pub(super) struct BroadcastRequest {
164 subject: String,
165 body: String,
166 }
167
168 /// POST /api/internal/creator/broadcast
169 #[tracing::instrument(skip_all, name = "internal::send_broadcast")]
170 pub(super) async fn send_broadcast(
171 State(db): State<PgPool>,
172 State(config): State<Config>,
173 State(email): State<EmailClient>,
174 actor: InternalActor,
175 _auth: ServiceAuth,
176 Json(req): Json<BroadcastRequest>,
177 ) -> Result<impl IntoResponse> {
178 if req.subject.is_empty() || req.subject.len() > 200 {
179 return Err(AppError::validation(
180 "Subject must be 1-200 characters".to_string(),
181 ));
182 }
183 if req.body.is_empty() || req.body.len() > 5000 {
184 return Err(AppError::validation(
185 "Body must be 1-5000 characters".to_string(),
186 ));
187 }
188
189 let db_user = db::users::get_user_by_id(&db, actor.user_id())
190 .await?
191 .ok_or(AppError::NotFound)?;
192
193 if !db_user.can_create_projects {
194 return Err(AppError::Forbidden);
195 }
196
197 let set = db::users::try_set_broadcast_at(&db, actor.user_id()).await?;
198 if !set {
199 return Err(AppError::validation(
200 "You can only send one broadcast per 24 hours".to_string(),
201 ));
202 }
203
204 // Enforce the broadcast recipient cap at the type level, the same
205 // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs)
206 // uses, so the two handlers can no longer drift on the cap check.
207 let followers = db::follows::get_follower_emails(&db, actor.user_id()).await?;
208 let recipients = match crate::email::BoundedRecipients::new(followers) {
209 Ok(r) => r,
210 Err(count) => {
211 // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted.
212 let _ = db::users::clear_broadcast_at(&db, actor.user_id()).await;
213 return Err(AppError::validation(format!(
214 "Broadcast would reach {count} followers, above the per-send limit of 10,000. \
215 Email info@makenot.work to lift the cap for your account."
216 )));
217 }
218 };
219 let count = recipients.len();
220
221 if count > 0 {
222 let followers = recipients.into_inner();
223 let creator_name = db_user
224 .display_name
225 .as_deref()
226 .unwrap_or(&db_user.username)
227 .to_string();
228 let host_url = config.host_url.clone();
229 let signing_secret = config.signing_secret.clone();
230 let creator_id = actor.user_id();
231 let subject = req.subject.clone();
232 let body = req.body.clone();
233 let email_client = email.clone();
234
235 tokio::spawn(async move {
236 let mut set = tokio::task::JoinSet::new();
237 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
238
239 for follower in followers {
240 if set.len() >= constants::BROADCAST_PARALLELISM {
241 let _ = set.join_next().await;
242 }
243
244 let email_client = email_client.clone();
245 let host_url = host_url.clone();
246 let signing_secret = signing_secret.clone();
247 let creator_name = creator_name.clone();
248 let subject = subject.clone();
249 let body = body.clone();
250 let creator_id_str = creator_id.to_string();
251
252 set.spawn(async move {
253 let unsub_url = crate::email::generate_unsubscribe_url(
254 &host_url,
255 follower.id,
256 crate::email::UnsubscribeAction::Broadcast,
257 &creator_id_str,
258 &signing_secret,
259 );
260 if let Err(e) = email_client
261 .send_broadcast(
262 &follower.email,
263 follower.display_name.as_deref(),
264 &creator_name,
265 &subject,
266 &body,
267 Some(&unsub_url),
268 )
269 .await
270 {
271 tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed");
272 }
273 });
274
275 tokio::time::sleep(chunk_delay).await;
276 }
277
278 while set.join_next().await.is_some() {}
279 });
280 }
281
282 Ok(Json(
283 serde_json::json!({"success": true, "recipient_count": count}),
284 ))
285 }
286
287 // --- Tiers ---
288
289 #[derive(Serialize)]
290 struct TierView {
291 id: String,
292 name: String,
293 description: String,
294 price_cents: i32,
295 is_active: bool,
296 }
297
298 /// GET /api/internal/creator/projects/{id}/tiers?user_id=...
299 #[tracing::instrument(skip_all, name = "internal::list_tiers")]
300 pub(super) async fn list_tiers(
301 State(db): State<PgPool>,
302 actor: InternalActor,
303 _auth: ServiceAuth,
304 axum::extract::Path(project_id): axum::extract::Path<ProjectId>,
305 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
306 ) -> Result<impl IntoResponse> {
307 let project = db::projects::get_project_by_id(&db, project_id)
308 .await?
309 .ok_or(AppError::NotFound)?;
310 if project.user_id != actor.user_id() {
311 return Err(AppError::Forbidden);
312 }
313
314 let tiers = db::subscriptions::get_all_tiers_by_project(&db, project_id).await?;
315 let views: Vec<TierView> = tiers
316 .iter()
317 .map(|t| TierView {
318 id: t.id.to_string(),
319 name: t.name.clone(),
320 description: t.description.clone().unwrap_or_default(),
321 price_cents: t.price_cents,
322 is_active: t.is_active,
323 })
324 .collect();
325
326 Ok(Json(views))
327 }
328
329 // --- Collections ---
330
331 #[derive(Deserialize)]
332 pub(super) struct CreateCollectionRequest {
333 slug: String,
334 title: String,
335 description: Option<String>,
336 is_public: Option<bool>,
337 }
338
339 #[derive(Serialize)]
340 struct CollectionView {
341 id: String,
342 slug: String,
343 title: String,
344 description: String,
345 is_public: bool,
346 item_count: i64,
347 }
348
349 /// GET /api/internal/creator/collections?user_id=...
350 #[tracing::instrument(skip_all, name = "internal::list_collections")]
351 pub(super) async fn list_collections(
352 State(db): State<PgPool>,
353 actor: InternalActor,
354 _auth: ServiceAuth,
355 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
356 ) -> Result<impl IntoResponse> {
357 let collections = db::collections::get_collections_by_user(&db, actor.user_id()).await?;
358 let views: Vec<CollectionView> = collections
359 .iter()
360 .map(|c| CollectionView {
361 id: c.id.to_string(),
362 slug: c.slug.to_string(),
363 title: c.title.clone(),
364 description: c.description.clone().unwrap_or_default(),
365 is_public: c.is_public,
366 item_count: c.item_count,
367 })
368 .collect();
369
370 Ok(Json(views))
371 }
372
373 /// POST /api/internal/creator/collections
374 #[tracing::instrument(skip_all, name = "internal::create_collection")]
375 pub(super) async fn create_collection(
376 State(db): State<PgPool>,
377 actor: InternalActor,
378 _auth: ServiceAuth,
379 Json(req): Json<CreateCollectionRequest>,
380 ) -> Result<impl IntoResponse> {
381 let slug = Slug::new(&req.slug).map_err(|e| AppError::validation(e.to_string()))?;
382
383 let collection = db::collections::create_collection(
384 &db,
385 actor.user_id(),
386 &slug,
387 &req.title,
388 req.description.as_deref(),
389 req.is_public.unwrap_or(true),
390 )
391 .await?;
392
393 Ok(Json(serde_json::json!({
394 "id": collection.id.to_string(),
395 "slug": collection.slug.to_string(),
396 "title": collection.title,
397 })))
398 }
399
400 /// DELETE /api/internal/creator/collections/{id}?user_id=...
401 #[tracing::instrument(skip_all, name = "internal::delete_collection")]
402 pub(super) async fn delete_collection(
403 State(db): State<PgPool>,
404 actor: InternalActor,
405 _auth: ServiceAuth,
406 axum::extract::Path(collection_id): axum::extract::Path<CollectionId>,
407 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
408 ) -> Result<impl IntoResponse> {
409 let collection = db::collections::get_collection_by_id(&db, collection_id)
410 .await?
411 .ok_or(AppError::NotFound)?;
412 if collection.user_id != actor.user_id() {
413 return Err(AppError::Forbidden);
414 }
415
416 db::collections::delete_collection(&db, collection_id, actor.user_id()).await?;
417
418 Ok(axum::http::StatusCode::NO_CONTENT)
419 }
420
421 // --- Custom Domains ---
422
423 #[derive(Deserialize)]
424 pub(super) struct AddDomainRequest {
425 domain: String,
426 }
427
428 /// GET /api/internal/creator/domain?user_id=...
429 #[tracing::instrument(skip_all, name = "internal::get_domain")]
430 pub(super) async fn get_domain(
431 State(db): State<PgPool>,
432 actor: InternalActor,
433 _auth: ServiceAuth,
434 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
435 ) -> Result<impl IntoResponse> {
436 let domain = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id()).await?;
437 match domain {
438 Some(d) => Ok(Json(serde_json::json!({
439 "id": d.id.to_string(),
440 "domain": d.domain,
441 "verified": d.verified,
442 "verification_token": d.verification_token,
443 }))),
444 None => Ok(Json(serde_json::json!(null))),
445 }
446 }
447
448 /// POST /api/internal/creator/domain
449 #[tracing::instrument(skip_all, name = "internal::add_domain")]
450 pub(super) async fn add_domain(
451 State(db): State<PgPool>,
452 actor: InternalActor,
453 _auth: ServiceAuth,
454 Json(req): Json<AddDomainRequest>,
455 ) -> Result<impl IntoResponse> {
456 let domain = req.domain.to_lowercase().trim().to_string();
457 // Reuse the web path's validator (per-label length + charset + hyphen-edge
458 // checks) instead of the weaker `contains('.')` gate, so the CLI and web
459 // domain paths enforce one contract (audit Run 13 API-consistency).
460 crate::routes::api::domains::validate_domain(&domain)?;
461
462 let token = generate_verification_token();
463 // Map the global `UNIQUE(domain)` violation to a clean 409, matching the web
464 // path, a domain another user already holds must not surface as a raw 500.
465 let record = db::custom_domains::create_custom_domain(&db, actor.user_id(), &domain, &token)
466 .await
467 .map_err(|e| {
468 crate::helpers::map_unique_violation(e, "That domain is already registered")
469 })?;
470
471 Ok(Json(serde_json::json!({
472 "id": record.id.to_string(),
473 "domain": record.domain,
474 "verified": record.verified,
475 "verification_token": record.verification_token,
476 "instructions": format!("Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.", record.domain, record.verification_token),
477 })))
478 }
479
480 /// POST /api/internal/creator/domain/verify?user_id=...
481 #[tracing::instrument(skip_all, name = "internal::verify_domain")]
482 pub(super) async fn verify_domain(
483 State(db): State<PgPool>,
484 State(caches): State<AppCaches>,
485 actor: InternalActor,
486 _auth: ServiceAuth,
487 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
488 ) -> Result<impl IntoResponse> {
489 let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id())
490 .await?
491 .ok_or(AppError::NotFound)?;
492
493 if record.verified {
494 return Ok(Json(
495 serde_json::json!({"verified": true, "message": "Already verified"}),
496 ));
497 }
498
499 // DNS lookup via Cloudflare DoH
500 let lookup_name = format!("_mnw-verify.{}", record.domain);
501 let url = format!("https://cloudflare-dns.com/dns-query?name={lookup_name}&type=TXT");
502 let resp = crate::helpers::HTTP_CLIENT
503 .get(&url)
504 .header("accept", "application/dns-json")
505 .timeout(std::time::Duration::from_secs(5))
506 .send()
507 .await
508 .context("dns lookup")?;
509
510 let json: serde_json::Value = resp.json().await.context("parse dns response")?;
511
512 let verified = json["Answer"].as_array().is_some_and(|answers| {
513 answers.iter().any(|a| {
514 a["data"]
515 .as_str()
516 .is_some_and(|d| d.trim_matches('"') == record.verification_token)
517 })
518 });
519
520 if verified {
521 db::custom_domains::mark_domain_verified(&db, record.id).await?;
522 caches
523 .domain_cache
524 .insert(record.domain.clone(), actor.user_id());
525 Ok(Json(
526 serde_json::json!({"verified": true, "message": "Domain verified"}),
527 ))
528 } else {
529 Ok(Json(
530 serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)}),
531 ))
532 }
533 }
534
535 /// DELETE /api/internal/creator/domain?user_id=...
536 #[tracing::instrument(skip_all, name = "internal::remove_domain")]
537 pub(super) async fn remove_domain(
538 State(db): State<PgPool>,
539 State(caches): State<AppCaches>,
540 actor: InternalActor,
541 _auth: ServiceAuth,
542 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
543 ) -> Result<impl IntoResponse> {
544 let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id())
545 .await?
546 .ok_or(AppError::NotFound)?;
547
548 db::custom_domains::delete_custom_domain(&db, record.id, actor.user_id()).await?;
549 caches.domain_cache.remove(&record.domain);
550
551 Ok(axum::http::StatusCode::NO_CONTENT)
552 }
553
554 fn generate_verification_token() -> String {
555 let mut bytes = [0u8; 16];
556 rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes);
557 format!("mnw-verify-{}", hex::encode(bytes))
558 }
559
560 /// Map a project type string to its feature flags. Unknown types default to
561 /// `["downloads"]` (the safest superset for an unrecognised request).
562 fn features_for_project_type(project_type: &str) -> Vec<String> {
563 match project_type {
564 "audio" => vec!["audio".to_string()],
565 "digital" => vec!["downloads".to_string()],
566 "video" => vec!["video".to_string()],
567 "mixed" => vec!["audio".to_string(), "downloads".to_string()],
568 "subscription" => vec!["subscriptions".to_string()],
569 _ => vec!["downloads".to_string()],
570 }
571 }
572
573 /// Derive a URL-safe slug from a title: lowercase, alphanumeric + space, then
574 /// collapse runs of whitespace to single hyphens. Returns `"project"` when the
575 /// input contains no alphanumerics.
576 fn slug_from_title(title: &str) -> String {
577 let s: String = title
578 .to_lowercase()
579 .chars()
580 .map(|c| {
581 if c.is_alphanumeric() || c == ' ' {
582 c
583 } else {
584 ' '
585 }
586 })
587 .collect::<String>()
588 .split_whitespace()
589 .collect::<Vec<_>>()
590 .join("-");
591 if s.is_empty() {
592 "project".to_string()
593 } else {
594 s
595 }
596 }
597
598 // --- Project creation ---
599
600 #[derive(Deserialize)]
601 pub(super) struct CreateProjectRequest {
602 title: String,
603 project_type: String,
604 description: Option<String>,
605 }
606
607 #[derive(Serialize)]
608 struct CreateProjectResponse {
609 id: String,
610 slug: String,
611 title: String,
612 project_type: String,
613 }
614
615 /// POST /api/internal/creator/projects
616 #[tracing::instrument(skip_all, name = "internal::create_project")]
617 pub(super) async fn create_project(
618 State(db): State<PgPool>,
619 actor: InternalActor,
620 _auth: ServiceAuth,
621 Json(req): Json<CreateProjectRequest>,
622 ) -> Result<impl IntoResponse> {
623 // Verify user can create projects
624 let user = db::users::get_user_by_id(&db, actor.user_id())
625 .await?
626 .ok_or(AppError::NotFound)?;
627
628 if !user.can_create_projects {
629 return Err(AppError::Forbidden);
630 }
631
632 if req.title.is_empty() || req.title.len() > 100 {
633 return Err(AppError::BadRequest(
634 "Title must be 1-100 characters".to_string(),
635 ));
636 }
637
638 let features = features_for_project_type(&req.project_type);
639 let slug = Slug::from_trusted(slug_from_title(&req.title));
640
641 let project = db::projects::create_project(
642 &db,
643 actor.user_id(),
644 &slug,
645 &req.title,
646 req.description.as_deref(),
647 &features,
648 )
649 .await?;
650
651 Ok(Json(CreateProjectResponse {
652 id: project.id.to_string(),
653 slug: project.slug.to_string(),
654 title: project.title,
655 project_type: project.project_type.to_string(),
656 }))
657 }
658
659 #[cfg(test)]
660 mod tests {
661 use super::*;
662
663 // --- generate_verification_token ---
664
665 #[test]
666 fn verification_token_has_expected_prefix_and_length() {
667 let t = generate_verification_token();
668 // "mnw-verify-" (11) + 32 hex chars (16 bytes × 2) = 43.
669 assert!(t.starts_with("mnw-verify-"), "token prefix wrong: {t}");
670 assert_eq!(t.len(), 11 + 32, "token length wrong: {t}");
671 let hex_part = &t[11..];
672 assert!(
673 hex_part.chars().all(|c| c.is_ascii_hexdigit()),
674 "non-hex suffix: {hex_part}"
675 );
676 }
677
678 #[test]
679 fn verification_tokens_are_unique() {
680 let a = generate_verification_token();
681 let b = generate_verification_token();
682 assert_ne!(a, b, "two tokens collided");
683 }
684
685 // --- features_for_project_type, each match arm ---
686
687 #[test]
688 fn features_audio() {
689 assert_eq!(
690 features_for_project_type("audio"),
691 vec!["audio".to_string()]
692 );
693 }
694
695 #[test]
696 fn features_digital() {
697 assert_eq!(
698 features_for_project_type("digital"),
699 vec!["downloads".to_string()]
700 );
701 }
702
703 #[test]
704 fn features_video() {
705 assert_eq!(
706 features_for_project_type("video"),
707 vec!["video".to_string()]
708 );
709 }
710
711 #[test]
712 fn features_mixed_combines_audio_and_downloads_in_order() {
713 // Pins ordering, `vec!["audio", "downloads"]` not the reverse.
714 assert_eq!(
715 features_for_project_type("mixed"),
716 vec!["audio".to_string(), "downloads".to_string()],
717 );
718 }
719
720 #[test]
721 fn features_subscription() {
722 assert_eq!(
723 features_for_project_type("subscription"),
724 vec!["subscriptions".to_string()]
725 );
726 }
727
728 #[test]
729 fn features_unknown_defaults_to_downloads() {
730 // Pins the `_ => vec!["downloads"]` fallback.
731 assert_eq!(
732 features_for_project_type("unknown"),
733 vec!["downloads".to_string()]
734 );
735 assert_eq!(features_for_project_type(""), vec!["downloads".to_string()]);
736 // Case-sensitive: "Audio" is not "audio".
737 assert_eq!(
738 features_for_project_type("Audio"),
739 vec!["downloads".to_string()]
740 );
741 }
742
743 // --- slug_from_title ---
744
745 #[test]
746 fn slug_lowercases_and_hyphenates_words() {
747 assert_eq!(slug_from_title("Hello World"), "hello-world");
748 }
749
750 #[test]
751 fn slug_strips_non_alphanumeric() {
752 // Pins `is_alphanumeric() || c == ' '`, punctuation becomes a space
753 // which then collapses with adjacent whitespace.
754 assert_eq!(slug_from_title("Project: A & B!"), "project-a-b");
755 }
756
757 #[test]
758 fn slug_collapses_runs_of_whitespace() {
759 assert_eq!(slug_from_title("a b\tc"), "a-b-c");
760 }
761
762 #[test]
763 fn slug_keeps_digits() {
764 assert_eq!(slug_from_title("V2 Beats"), "v2-beats");
765 }
766
767 #[test]
768 fn slug_unicode_alphanumeric_passes_through() {
769 // `is_alphanumeric()` is Unicode-aware. Lowercased Greek letters survive.
770 let s = slug_from_title("Λ Test");
771 // Don't pin the exact case-folded form; just that the alphanumerics are preserved.
772 assert!(s.contains("test"));
773 assert!(!s.is_empty());
774 }
775
776 #[test]
777 fn slug_empty_input_defaults_to_project() {
778 // Pins the `if s.is_empty() { "project" }` fallback.
779 assert_eq!(slug_from_title(""), "project");
780 assert_eq!(slug_from_title(" "), "project");
781 assert_eq!(slug_from_title("!!! ???"), "project");
782 }
783
784 #[test]
785 fn slug_single_word_no_hyphen() {
786 assert_eq!(slug_from_title("Solo"), "solo");
787 }
788
789 #[test]
790 fn slug_leading_trailing_whitespace_ignored() {
791 // split_whitespace handles edges.
792 assert_eq!(slug_from_title(" hello world "), "hello-world");
793 }
794 }
795