Skip to main content

max / makenotwork

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