Skip to main content

max / makenotwork

25.1 KB · 810 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 // The monthly allowance, the same gate the public twin applies. Bounds the
222 // count over the billing month, which neither the per-send cap above nor the
223 // 24h slot does (`db::mail_caps`).
224 let verdict = db::mail_caps::reserve(
225 &db,
226 actor.user_id(),
227 i64::try_from(count).unwrap_or(i64::MAX),
228 )
229 .await?;
230 if let Some(message) = verdict.refusal_message() {
231 // Roll back the 24h slot: the broadcast never left.
232 let _ = db::users::clear_broadcast_at(&db, actor.user_id()).await;
233 return Err(AppError::validation(message));
234 }
235
236 if count > 0 {
237 let followers = recipients.into_inner();
238 let creator_name = db_user
239 .display_name
240 .as_deref()
241 .unwrap_or(&db_user.username)
242 .to_string();
243 let host_url = config.host_url.clone();
244 let signing_secret = config.signing_secret.clone();
245 let creator_id = actor.user_id();
246 let subject = req.subject.clone();
247 let body = req.body.clone();
248 let email_client = email.clone();
249
250 tokio::spawn(async move {
251 let mut set = tokio::task::JoinSet::new();
252 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
253
254 for follower in followers {
255 if set.len() >= constants::BROADCAST_PARALLELISM {
256 let _ = set.join_next().await;
257 }
258
259 let email_client = email_client.clone();
260 let host_url = host_url.clone();
261 let signing_secret = signing_secret.clone();
262 let creator_name = creator_name.clone();
263 let subject = subject.clone();
264 let body = body.clone();
265 let creator_id_str = creator_id.to_string();
266
267 set.spawn(async move {
268 let unsub_url = crate::email::generate_unsubscribe_url(
269 &host_url,
270 follower.id,
271 crate::email::UnsubscribeAction::Broadcast,
272 &creator_id_str,
273 &signing_secret,
274 );
275 if let Err(e) = email_client
276 .send_broadcast(
277 &follower.email,
278 follower.display_name.as_deref(),
279 &creator_name,
280 &subject,
281 &body,
282 Some(&unsub_url),
283 )
284 .await
285 {
286 tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed");
287 }
288 });
289
290 tokio::time::sleep(chunk_delay).await;
291 }
292
293 while set.join_next().await.is_some() {}
294 });
295 }
296
297 Ok(Json(
298 serde_json::json!({"success": true, "recipient_count": count}),
299 ))
300 }
301
302 // --- Tiers ---
303
304 #[derive(Serialize)]
305 struct TierView {
306 id: String,
307 name: String,
308 description: String,
309 price_cents: i32,
310 is_active: bool,
311 }
312
313 /// GET /api/internal/creator/projects/{id}/tiers?user_id=...
314 #[tracing::instrument(skip_all, name = "internal::list_tiers")]
315 pub(super) async fn list_tiers(
316 State(db): State<PgPool>,
317 actor: InternalActor,
318 _auth: ServiceAuth,
319 axum::extract::Path(project_id): axum::extract::Path<ProjectId>,
320 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
321 ) -> Result<impl IntoResponse> {
322 let project = db::projects::get_project_by_id(&db, project_id)
323 .await?
324 .ok_or(AppError::NotFound)?;
325 if project.user_id != actor.user_id() {
326 return Err(AppError::Forbidden);
327 }
328
329 let tiers = db::subscriptions::get_all_tiers_by_project(&db, project_id).await?;
330 let views: Vec<TierView> = tiers
331 .iter()
332 .map(|t| TierView {
333 id: t.id.to_string(),
334 name: t.name.clone(),
335 description: t.description.clone().unwrap_or_default(),
336 price_cents: t.price_cents,
337 is_active: t.is_active,
338 })
339 .collect();
340
341 Ok(Json(views))
342 }
343
344 // --- Collections ---
345
346 #[derive(Deserialize)]
347 pub(super) struct CreateCollectionRequest {
348 slug: String,
349 title: String,
350 description: Option<String>,
351 is_public: Option<bool>,
352 }
353
354 #[derive(Serialize)]
355 struct CollectionView {
356 id: String,
357 slug: String,
358 title: String,
359 description: String,
360 is_public: bool,
361 item_count: i64,
362 }
363
364 /// GET /api/internal/creator/collections?user_id=...
365 #[tracing::instrument(skip_all, name = "internal::list_collections")]
366 pub(super) async fn list_collections(
367 State(db): State<PgPool>,
368 actor: InternalActor,
369 _auth: ServiceAuth,
370 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
371 ) -> Result<impl IntoResponse> {
372 let collections = db::collections::get_collections_by_user(&db, actor.user_id()).await?;
373 let views: Vec<CollectionView> = collections
374 .iter()
375 .map(|c| CollectionView {
376 id: c.id.to_string(),
377 slug: c.slug.to_string(),
378 title: c.title.clone(),
379 description: c.description.clone().unwrap_or_default(),
380 is_public: c.is_public,
381 item_count: c.item_count,
382 })
383 .collect();
384
385 Ok(Json(views))
386 }
387
388 /// POST /api/internal/creator/collections
389 #[tracing::instrument(skip_all, name = "internal::create_collection")]
390 pub(super) async fn create_collection(
391 State(db): State<PgPool>,
392 actor: InternalActor,
393 _auth: ServiceAuth,
394 Json(req): Json<CreateCollectionRequest>,
395 ) -> Result<impl IntoResponse> {
396 let slug = Slug::new(&req.slug).map_err(|e| AppError::validation(e.to_string()))?;
397
398 let collection = db::collections::create_collection(
399 &db,
400 actor.user_id(),
401 &slug,
402 &req.title,
403 req.description.as_deref(),
404 req.is_public.unwrap_or(true),
405 )
406 .await?;
407
408 Ok(Json(serde_json::json!({
409 "id": collection.id.to_string(),
410 "slug": collection.slug.to_string(),
411 "title": collection.title,
412 })))
413 }
414
415 /// DELETE /api/internal/creator/collections/{id}?user_id=...
416 #[tracing::instrument(skip_all, name = "internal::delete_collection")]
417 pub(super) async fn delete_collection(
418 State(db): State<PgPool>,
419 actor: InternalActor,
420 _auth: ServiceAuth,
421 axum::extract::Path(collection_id): axum::extract::Path<CollectionId>,
422 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
423 ) -> Result<impl IntoResponse> {
424 let collection = db::collections::get_collection_by_id(&db, collection_id)
425 .await?
426 .ok_or(AppError::NotFound)?;
427 if collection.user_id != actor.user_id() {
428 return Err(AppError::Forbidden);
429 }
430
431 db::collections::delete_collection(&db, collection_id, actor.user_id()).await?;
432
433 Ok(axum::http::StatusCode::NO_CONTENT)
434 }
435
436 // --- Custom Domains ---
437
438 #[derive(Deserialize)]
439 pub(super) struct AddDomainRequest {
440 domain: String,
441 }
442
443 /// GET /api/internal/creator/domain?user_id=...
444 #[tracing::instrument(skip_all, name = "internal::get_domain")]
445 pub(super) async fn get_domain(
446 State(db): State<PgPool>,
447 actor: InternalActor,
448 _auth: ServiceAuth,
449 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
450 ) -> Result<impl IntoResponse> {
451 let domain = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id()).await?;
452 match domain {
453 Some(d) => Ok(Json(serde_json::json!({
454 "id": d.id.to_string(),
455 "domain": d.domain,
456 "verified": d.verified,
457 "verification_token": d.verification_token,
458 }))),
459 None => Ok(Json(serde_json::json!(null))),
460 }
461 }
462
463 /// POST /api/internal/creator/domain
464 #[tracing::instrument(skip_all, name = "internal::add_domain")]
465 pub(super) async fn add_domain(
466 State(db): State<PgPool>,
467 actor: InternalActor,
468 _auth: ServiceAuth,
469 Json(req): Json<AddDomainRequest>,
470 ) -> Result<impl IntoResponse> {
471 let domain = req.domain.to_lowercase().trim().to_string();
472 // Reuse the web path's validator (per-label length + charset + hyphen-edge
473 // checks) instead of the weaker `contains('.')` gate, so the CLI and web
474 // domain paths enforce one contract (audit Run 13 API-consistency).
475 crate::routes::api::domains::validate_domain(&domain)?;
476
477 let token = generate_verification_token();
478 // Map the global `UNIQUE(domain)` violation to a clean 409, matching the web
479 // path, a domain another user already holds must not surface as a raw 500.
480 let record = db::custom_domains::create_custom_domain(&db, actor.user_id(), &domain, &token)
481 .await
482 .map_err(|e| {
483 crate::helpers::map_unique_violation(e, "That domain is already registered")
484 })?;
485
486 Ok(Json(serde_json::json!({
487 "id": record.id.to_string(),
488 "domain": record.domain,
489 "verified": record.verified,
490 "verification_token": record.verification_token,
491 "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),
492 })))
493 }
494
495 /// POST /api/internal/creator/domain/verify?user_id=...
496 #[tracing::instrument(skip_all, name = "internal::verify_domain")]
497 pub(super) async fn verify_domain(
498 State(db): State<PgPool>,
499 State(caches): State<AppCaches>,
500 actor: InternalActor,
501 _auth: ServiceAuth,
502 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
503 ) -> Result<impl IntoResponse> {
504 let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id())
505 .await?
506 .ok_or(AppError::NotFound)?;
507
508 if record.verified {
509 return Ok(Json(
510 serde_json::json!({"verified": true, "message": "Already verified"}),
511 ));
512 }
513
514 // DNS lookup via Cloudflare DoH
515 let lookup_name = format!("_mnw-verify.{}", record.domain);
516 let url = format!("https://cloudflare-dns.com/dns-query?name={lookup_name}&type=TXT");
517 let resp = crate::helpers::HTTP_CLIENT
518 .get(&url)
519 .header("accept", "application/dns-json")
520 .timeout(std::time::Duration::from_secs(5))
521 .send()
522 .await
523 .context("dns lookup")?;
524
525 let json: serde_json::Value = resp.json().await.context("parse dns response")?;
526
527 let verified = json["Answer"].as_array().is_some_and(|answers| {
528 answers.iter().any(|a| {
529 a["data"]
530 .as_str()
531 .is_some_and(|d| d.trim_matches('"') == record.verification_token)
532 })
533 });
534
535 if verified {
536 db::custom_domains::mark_domain_verified(&db, record.id).await?;
537 caches
538 .domain_cache
539 .insert(record.domain.clone(), actor.user_id());
540 Ok(Json(
541 serde_json::json!({"verified": true, "message": "Domain verified"}),
542 ))
543 } else {
544 Ok(Json(
545 serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)}),
546 ))
547 }
548 }
549
550 /// DELETE /api/internal/creator/domain?user_id=...
551 #[tracing::instrument(skip_all, name = "internal::remove_domain")]
552 pub(super) async fn remove_domain(
553 State(db): State<PgPool>,
554 State(caches): State<AppCaches>,
555 actor: InternalActor,
556 _auth: ServiceAuth,
557 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
558 ) -> Result<impl IntoResponse> {
559 let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id())
560 .await?
561 .ok_or(AppError::NotFound)?;
562
563 db::custom_domains::delete_custom_domain(&db, record.id, actor.user_id()).await?;
564 caches.domain_cache.remove(&record.domain);
565
566 Ok(axum::http::StatusCode::NO_CONTENT)
567 }
568
569 fn generate_verification_token() -> String {
570 let mut bytes = [0u8; 16];
571 rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes);
572 format!("mnw-verify-{}", hex::encode(bytes))
573 }
574
575 /// Map a project type string to its feature flags. Unknown types default to
576 /// `["downloads"]` (the safest superset for an unrecognised request).
577 fn features_for_project_type(project_type: &str) -> Vec<String> {
578 match project_type {
579 "audio" => vec!["audio".to_string()],
580 "digital" => vec!["downloads".to_string()],
581 "video" => vec!["video".to_string()],
582 "mixed" => vec!["audio".to_string(), "downloads".to_string()],
583 "subscription" => vec!["subscriptions".to_string()],
584 _ => vec!["downloads".to_string()],
585 }
586 }
587
588 /// Derive a URL-safe slug from a title: lowercase, alphanumeric + space, then
589 /// collapse runs of whitespace to single hyphens. Returns `"project"` when the
590 /// input contains no alphanumerics.
591 fn slug_from_title(title: &str) -> String {
592 let s: String = title
593 .to_lowercase()
594 .chars()
595 .map(|c| {
596 if c.is_alphanumeric() || c == ' ' {
597 c
598 } else {
599 ' '
600 }
601 })
602 .collect::<String>()
603 .split_whitespace()
604 .collect::<Vec<_>>()
605 .join("-");
606 if s.is_empty() {
607 "project".to_string()
608 } else {
609 s
610 }
611 }
612
613 // --- Project creation ---
614
615 #[derive(Deserialize)]
616 pub(super) struct CreateProjectRequest {
617 title: String,
618 project_type: String,
619 description: Option<String>,
620 }
621
622 #[derive(Serialize)]
623 struct CreateProjectResponse {
624 id: String,
625 slug: String,
626 title: String,
627 project_type: String,
628 }
629
630 /// POST /api/internal/creator/projects
631 #[tracing::instrument(skip_all, name = "internal::create_project")]
632 pub(super) async fn create_project(
633 State(db): State<PgPool>,
634 actor: InternalActor,
635 _auth: ServiceAuth,
636 Json(req): Json<CreateProjectRequest>,
637 ) -> Result<impl IntoResponse> {
638 // Verify user can create projects
639 let user = db::users::get_user_by_id(&db, actor.user_id())
640 .await?
641 .ok_or(AppError::NotFound)?;
642
643 if !user.can_create_projects {
644 return Err(AppError::Forbidden);
645 }
646
647 if req.title.is_empty() || req.title.len() > 100 {
648 return Err(AppError::BadRequest(
649 "Title must be 1-100 characters".to_string(),
650 ));
651 }
652
653 let features = features_for_project_type(&req.project_type);
654 let slug = Slug::from_trusted(slug_from_title(&req.title));
655
656 let project = db::projects::create_project(
657 &db,
658 actor.user_id(),
659 &slug,
660 &req.title,
661 req.description.as_deref(),
662 &features,
663 )
664 .await?;
665
666 Ok(Json(CreateProjectResponse {
667 id: project.id.to_string(),
668 slug: project.slug.to_string(),
669 title: project.title,
670 project_type: project.project_type.to_string(),
671 }))
672 }
673
674 #[cfg(test)]
675 mod tests {
676 use super::*;
677
678 // --- generate_verification_token ---
679
680 #[test]
681 fn verification_token_has_expected_prefix_and_length() {
682 let t = generate_verification_token();
683 // "mnw-verify-" (11) + 32 hex chars (16 bytes × 2) = 43.
684 assert!(t.starts_with("mnw-verify-"), "token prefix wrong: {t}");
685 assert_eq!(t.len(), 11 + 32, "token length wrong: {t}");
686 let hex_part = &t[11..];
687 assert!(
688 hex_part.chars().all(|c| c.is_ascii_hexdigit()),
689 "non-hex suffix: {hex_part}"
690 );
691 }
692
693 #[test]
694 fn verification_tokens_are_unique() {
695 let a = generate_verification_token();
696 let b = generate_verification_token();
697 assert_ne!(a, b, "two tokens collided");
698 }
699
700 // --- features_for_project_type, each match arm ---
701
702 #[test]
703 fn features_audio() {
704 assert_eq!(
705 features_for_project_type("audio"),
706 vec!["audio".to_string()]
707 );
708 }
709
710 #[test]
711 fn features_digital() {
712 assert_eq!(
713 features_for_project_type("digital"),
714 vec!["downloads".to_string()]
715 );
716 }
717
718 #[test]
719 fn features_video() {
720 assert_eq!(
721 features_for_project_type("video"),
722 vec!["video".to_string()]
723 );
724 }
725
726 #[test]
727 fn features_mixed_combines_audio_and_downloads_in_order() {
728 // Pins ordering, `vec!["audio", "downloads"]` not the reverse.
729 assert_eq!(
730 features_for_project_type("mixed"),
731 vec!["audio".to_string(), "downloads".to_string()],
732 );
733 }
734
735 #[test]
736 fn features_subscription() {
737 assert_eq!(
738 features_for_project_type("subscription"),
739 vec!["subscriptions".to_string()]
740 );
741 }
742
743 #[test]
744 fn features_unknown_defaults_to_downloads() {
745 // Pins the `_ => vec!["downloads"]` fallback.
746 assert_eq!(
747 features_for_project_type("unknown"),
748 vec!["downloads".to_string()]
749 );
750 assert_eq!(features_for_project_type(""), vec!["downloads".to_string()]);
751 // Case-sensitive: "Audio" is not "audio".
752 assert_eq!(
753 features_for_project_type("Audio"),
754 vec!["downloads".to_string()]
755 );
756 }
757
758 // --- slug_from_title ---
759
760 #[test]
761 fn slug_lowercases_and_hyphenates_words() {
762 assert_eq!(slug_from_title("Hello World"), "hello-world");
763 }
764
765 #[test]
766 fn slug_strips_non_alphanumeric() {
767 // Pins `is_alphanumeric() || c == ' '`, punctuation becomes a space
768 // which then collapses with adjacent whitespace.
769 assert_eq!(slug_from_title("Project: A & B!"), "project-a-b");
770 }
771
772 #[test]
773 fn slug_collapses_runs_of_whitespace() {
774 assert_eq!(slug_from_title("a b\tc"), "a-b-c");
775 }
776
777 #[test]
778 fn slug_keeps_digits() {
779 assert_eq!(slug_from_title("V2 Beats"), "v2-beats");
780 }
781
782 #[test]
783 fn slug_unicode_alphanumeric_passes_through() {
784 // `is_alphanumeric()` is Unicode-aware. Lowercased Greek letters survive.
785 let s = slug_from_title("Λ Test");
786 // Don't pin the exact case-folded form; just that the alphanumerics are preserved.
787 assert!(s.contains("test"));
788 assert!(!s.is_empty());
789 }
790
791 #[test]
792 fn slug_empty_input_defaults_to_project() {
793 // Pins the `if s.is_empty() { "project" }` fallback.
794 assert_eq!(slug_from_title(""), "project");
795 assert_eq!(slug_from_title(" "), "project");
796 assert_eq!(slug_from_title("!!! ???"), "project");
797 }
798
799 #[test]
800 fn slug_single_word_no_hyphen() {
801 assert_eq!(slug_from_title("Solo"), "solo");
802 }
803
804 #[test]
805 fn slug_leading_trailing_whitespace_ignored() {
806 // split_whitespace handles edges.
807 assert_eq!(slug_from_title(" hello world "), "hello-world");
808 }
809 }
810