1use derive_builder::Builder;
39use serde::Serialize;
40use uuid::Uuid;
41
42use crate::HttpClientRef;
43use mangadex_api_schema::v5::{LocalizedString, MangaData};
44use mangadex_api_types::{ContentRating, Demographic, Language, MangaLinks, MangaStatus};
45
46#[cfg_attr(
52 feature = "deserializable-endpoint",
53 derive(serde::Deserialize, getset::Getters, getset::Setters)
54)]
55#[derive(Debug, Serialize, Clone, Builder, Default)]
56#[serde(rename_all = "camelCase")]
57#[builder(
58 setter(into, strip_option),
59 build_fn(error = "mangadex_api_types::error::BuilderError")
60)]
61#[cfg_attr(feature = "non_exhaustive", non_exhaustive)]
62pub struct CreateManga {
63 #[doc(hidden)]
65 #[serde(skip)]
66 #[builder(pattern = "immutable")]
67 #[cfg_attr(feature = "deserializable-endpoint", getset(set = "pub", get = "pub"))]
68 pub http_client: HttpClientRef,
69
70 #[builder(setter(each = "add_title"))]
71 pub title: LocalizedString,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 #[builder(default)]
74 pub alt_titles: Option<Vec<LocalizedString>>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 #[builder(default)]
77 pub description: Option<LocalizedString>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 #[builder(default)]
80 pub authors: Option<Vec<Uuid>>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 #[builder(default)]
83 pub artists: Option<Vec<Uuid>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 #[builder(default)]
86 pub links: Option<MangaLinks>,
87 pub original_language: Language,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 #[builder(default)]
90 pub last_volume: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 #[builder(default)]
93 pub last_chapter: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 #[builder(default)]
96 pub publication_demographic: Option<Option<Demographic>>,
97 pub status: MangaStatus,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 #[builder(default)]
101 pub year: Option<Option<u16>>,
102 pub content_rating: ContentRating,
103 #[builder(default)]
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub chapter_numbers_reset_on_new_volume: Option<bool>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 #[builder(default)]
108 pub tags: Option<Vec<Uuid>>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 #[builder(default)]
112 pub primary_cover: Option<Option<Uuid>>,
113 pub version: u32,
115}
116
117endpoint! {
118 POST "/manga",
119 #[body auth] CreateManga,
120 #[rate_limited] MangaData,
121 CreateMangaBuilder
122}
123
124#[cfg(test)]
125mod tests {
126 use serde_json::json;
127 use time::OffsetDateTime;
128 use url::Url;
129 use uuid::Uuid;
130 use wiremock::matchers::{body_json, header, method, path};
131 use wiremock::{Mock, MockServer, ResponseTemplate};
132
133 use crate::v5::AuthTokens;
134 use crate::{HttpClient, MangaDexClient};
135 use mangadex_api_types::{
136 ContentRating, Demographic, Language, MangaDexDateTime, MangaStatus, ResponseType, Tag,
137 };
138
139 #[tokio::test]
140 async fn create_manga_fires_a_request_to_base_url() -> anyhow::Result<()> {
141 let mock_server = MockServer::start().await;
142 let http_client: HttpClient = HttpClient::builder()
143 .base_url(Url::parse(&mock_server.uri())?)
144 .auth_tokens(AuthTokens {
145 session: "sessiontoken".to_string(),
146 refresh: "refreshtoken".to_string(),
147 })
148 .build()?;
149 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
150
151 let manga_id = Uuid::new_v4();
152 let tag_id: Uuid = Tag::Action.into();
153 let manga_title = "Test Manga".to_string();
154
155 let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
156
157 let _expected_body = json!({
158 "title": {
159 "en": manga_title
160 },
161 "originalLanguage": "ja",
162 "publicationDemographic": "shounen",
163 "status": "ongoing",
164 "contentRating": "safe",
165 "tags": [tag_id],
166 "version": 1
167 });
168 let response_body = json!({
169 "result": "ok",
170 "response": "entity",
171 "data": {
172 "id": manga_id,
173 "type": "manga",
174 "attributes": {
175 "title": {
176 "en": manga_title
177 },
178 "altTitles": [],
179 "description": {},
180 "isLocked": false,
181 "links": null,
182 "originalLanguage": "ja",
183 "lastVolume": null,
184 "lastChapter": null,
185 "publicationDemographic": "shounen",
186 "status": "ongoing",
187 "year": null,
188 "contentRating": "safe",
189 "chapterNumbersResetOnNewVolume": true,
190 "availableTranslatedLanguages": ["en"],
191 "tags": [
192 {
193 "id": tag_id,
194 "type": "tag",
195 "attributes": {
196 "name": {
197 "en": "Action"
198 },
199 "description": [],
200 "group": "genre",
201 "version": 1
202 },
203 "relationships": []
204 }
205 ],
206 "state": "draft",
207 "createdAt": datetime.to_string(),
208 "updatedAt": datetime.to_string(),
209
210 "version": 1
211 },
212 "relationships": []
213 }
214 });
215
216 Mock::given(method("POST"))
217 .and(path("/manga"))
218 .and(header("Authorization", "Bearer sessiontoken"))
219 .and(header("Content-Type", "application/json"))
220 .and(body_json(_expected_body))
221 .respond_with(
222 ResponseTemplate::new(201)
223 .insert_header("x-ratelimit-retry-after", "1698723860")
224 .insert_header("x-ratelimit-limit", "40")
225 .insert_header("x-ratelimit-remaining", "39")
226 .set_body_json(response_body),
227 )
228 .expect(1)
229 .mount(&mock_server)
230 .await;
231
232 let res = mangadex_client
233 .manga()
234 .post()
235 .add_title((Language::English, manga_title.clone()))
236 .original_language(Language::Japanese)
237 .publication_demographic(Demographic::Shounen)
238 .status(MangaStatus::Ongoing)
239 .content_rating(ContentRating::Safe)
240 .tags(vec![Tag::Action.into()])
241 .version(1_u32)
242 .send()
243 .await?;
244 let res = res.body;
245 assert_eq!(res.response, ResponseType::Entity);
246 assert_eq!(res.data.id, manga_id);
247 assert_eq!(
248 res.data.attributes.title.get(&Language::English).unwrap(),
249 &manga_title
250 );
251 assert!(res.data.attributes.alt_titles.is_empty());
252 assert!(res.data.attributes.description.is_empty());
253 assert!(!res.data.attributes.is_locked);
254 assert_eq!(res.data.attributes.links, None);
255 assert_eq!(res.data.attributes.original_language, Language::Japanese);
256 assert_eq!(res.data.attributes.last_volume, None);
257 assert_eq!(res.data.attributes.last_chapter, None);
258 assert_eq!(
259 res.data.attributes.publication_demographic.unwrap(),
260 Demographic::Shounen
261 );
262 assert_eq!(res.data.attributes.status, MangaStatus::Ongoing);
263 assert_eq!(res.data.attributes.year, None);
264 assert_eq!(
265 res.data.attributes.content_rating.unwrap(),
266 ContentRating::Safe
267 );
268 assert!(res.data.attributes.chapter_numbers_reset_on_new_volume);
269 assert_eq!(
270 res.data.attributes.available_translated_languages[0],
271 Language::English
272 );
273 assert_eq!(
274 res.data.attributes.tags[0]
275 .attributes
276 .name
277 .get(&Language::English),
278 Some(&"Action".to_string())
279 );
280 assert_eq!(
281 res.data.attributes.created_at.to_string(),
282 datetime.to_string()
283 );
284 assert_eq!(
285 res.data.attributes.updated_at.as_ref().unwrap().to_string(),
286 datetime.to_string()
287 );
288 assert_eq!(res.data.attributes.version, 1);
289
290 Ok(())
291 }
292
293 #[tokio::test]
294 async fn create_manga_does_not_include_last_volume_when_not_used() -> anyhow::Result<()> {
295 let mock_server = MockServer::start().await;
296 let http_client: HttpClient = HttpClient::builder()
297 .base_url(Url::parse(&mock_server.uri())?)
298 .auth_tokens(AuthTokens {
299 session: "sessiontoken".to_string(),
300 refresh: "refreshtoken".to_string(),
301 })
302 .build()?;
303 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
304
305 let manga_id = Uuid::new_v4();
306 let tag_id: Uuid = Tag::Action.into();
307 let manga_title = "Test Manga".to_string();
308
309 let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
310
311 let expected_body = json!({
312 "title": {
313 "en": manga_title
314 },
315 "originalLanguage": "ja",
316 "publicationDemographic": "shounen",
317 "status": "ongoing",
318 "contentRating": "safe",
319 "tags": [tag_id],
320 "version": 1
321 });
322 let response_body = json!({
323 "result": "ok",
324 "response": "entity",
325 "data": {
326 "id": manga_id,
327 "type": "manga",
328 "attributes": {
329 "title": {
330 "en": manga_title
331 },
332 "altTitles": [],
333 "description": {},
334 "isLocked": false,
335 "links": null,
336 "originalLanguage": "ja",
337 "lastVolume": null,
338 "lastChapter": null,
339 "publicationDemographic": "shounen",
340 "status": "ongoing",
341 "year": null,
342 "contentRating": "safe",
343 "chapterNumbersResetOnNewVolume": true,
344 "availableTranslatedLanguages": ["en"],
345 "tags": [
346 {
347 "id": tag_id,
348 "type": "tag",
349 "attributes": {
350 "name": {
351 "en": "Action"
352 },
353 "description": [],
354 "group": "genre",
355 "version": 1
356 },
357 "relationships": []
358 }
359 ],
360 "state": "draft",
361 "createdAt": datetime.to_string(),
362 "updatedAt": datetime.to_string(),
363
364 "version": 1
365 },
366 "relationships": []
367 }
368 });
369
370 Mock::given(method("POST"))
371 .and(path("/manga"))
372 .and(header("Authorization", "Bearer sessiontoken"))
373 .and(header("Content-Type", "application/json"))
374 .and(body_json(expected_body))
375 .respond_with(
376 ResponseTemplate::new(201)
377 .insert_header("x-ratelimit-retry-after", "1698723860")
378 .insert_header("x-ratelimit-limit", "40")
379 .insert_header("x-ratelimit-remaining", "39")
380 .set_body_json(response_body),
381 )
382 .expect(1)
383 .mount(&mock_server)
384 .await;
385
386 let res = mangadex_client
387 .manga()
388 .post()
389 .add_title((Language::English, manga_title.clone()))
390 .original_language(Language::Japanese)
391 .status(MangaStatus::Ongoing)
392 .content_rating(ContentRating::Safe)
393 .tags(vec![Tag::Action.into()])
394 .publication_demographic(Demographic::Shounen)
395 .version(1_u32)
396 .send()
397 .await?;
398
399 let res = res.body;
400
401 assert_eq!(res.data.attributes.last_volume, None);
402
403 Ok(())
404 }
405
406 #[tokio::test]
407 async fn create_manga_sends_null_last_volume() -> anyhow::Result<()> {
408 let mock_server = MockServer::start().await;
409 let http_client: HttpClient = HttpClient::builder()
410 .base_url(Url::parse(&mock_server.uri())?)
411 .auth_tokens(AuthTokens {
412 session: "sessiontoken".to_string(),
413 refresh: "refreshtoken".to_string(),
414 })
415 .build()?;
416 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
417
418 let manga_id = Uuid::new_v4();
419 let tag_id: Uuid = Tag::Action.into();
420 let manga_title = "Test Manga".to_string();
421
422 let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
423
424 let expected_body = json!({
425 "title": {
426 "en": manga_title
427 },
428 "originalLanguage": "ja",
429 "publicationDemographic": "shounen",
430 "status": "ongoing",
431 "contentRating": "safe",
432 "tags": [tag_id],
433 "version": 1
434 });
435 let response_body = json!({
436 "result": "ok",
437 "response": "entity",
438 "data": {
439 "id": manga_id,
440 "type": "manga",
441 "attributes": {
442 "title": {
443 "en": manga_title
444 },
445 "altTitles": [],
446 "description": {},
447 "isLocked": false,
448 "links": null,
449 "originalLanguage": "ja",
450 "lastVolume": null,
451 "lastChapter": null,
452 "publicationDemographic": "shounen",
453 "status": "ongoing",
454 "year": null,
455 "contentRating": "safe",
456 "chapterNumbersResetOnNewVolume": true,
457 "availableTranslatedLanguages": ["en"],
458 "tags": [
459 {
460 "id": tag_id,
461 "type": "tag",
462 "attributes": {
463 "name": {
464 "en": "Action"
465 },
466 "description": [],
467 "group": "genre",
468 "version": 1
469 },
470 "relationships": []
471 }
472 ],
473 "state": "draft",
474 "createdAt": datetime.to_string(),
475 "updatedAt": datetime.to_string(),
476
477 "version": 1
478 },
479 "relationships": []
480 }
481 });
482
483 Mock::given(method("POST"))
484 .and(path("/manga"))
485 .and(header("Authorization", "Bearer sessiontoken"))
486 .and(header("Content-Type", "application/json"))
487 .and(body_json(expected_body))
488 .respond_with(
489 ResponseTemplate::new(201)
490 .insert_header("x-ratelimit-retry-after", "1698723860")
491 .insert_header("x-ratelimit-limit", "40")
492 .insert_header("x-ratelimit-remaining", "39")
493 .set_body_json(response_body),
494 )
495 .expect(1)
496 .mount(&mock_server)
497 .await;
498
499 let res = mangadex_client
500 .manga()
501 .post()
502 .add_title((Language::English, manga_title.clone()))
503 .original_language(Language::Japanese)
504 .status(MangaStatus::Ongoing)
505 .content_rating(ContentRating::Safe)
506 .publication_demographic(Demographic::Shounen)
507 .tags(vec![Tag::Action.into()])
508 .version(1_u32)
509 .send()
510 .await?;
511
512 let res = res.body;
513
514 assert_eq!(res.data.attributes.last_volume, None);
515
516 Ok(())
517 }
518
519 #[tokio::test]
520 async fn create_manga_sends_last_volume_with_value() -> anyhow::Result<()> {
521 let mock_server = MockServer::start().await;
522 let http_client: HttpClient = HttpClient::builder()
523 .base_url(Url::parse(&mock_server.uri())?)
524 .auth_tokens(AuthTokens {
525 session: "sessiontoken".to_string(),
526 refresh: "refreshtoken".to_string(),
527 })
528 .build()?;
529 let mangadex_client = MangaDexClient::new_with_http_client(http_client);
530
531 let manga_id = Uuid::new_v4();
532 let tag_id: Uuid = Tag::Action.into();
533 let manga_title = "Test Manga".to_string();
534
535 let datetime = MangaDexDateTime::new(&OffsetDateTime::now_utc());
536
537 let expected_body = json!({
538 "title": {
539 "en": manga_title
540 },
541 "originalLanguage": "ja",
542 "lastVolume": "1",
543 "publicationDemographic": "shounen",
544 "status": "ongoing",
545 "contentRating": "safe",
546 "tags": [tag_id],
547 "version": 1
548 });
549 let response_body = json!({
550 "result": "ok",
551 "response": "entity",
552 "data": {
553 "id": manga_id,
554 "type": "manga",
555 "attributes": {
556 "title": {
557 "en": manga_title
558 },
559 "altTitles": [],
560 "description": {},
561 "isLocked": false,
562 "links": null,
563 "originalLanguage": "ja",
564 "lastVolume": "1",
565 "lastChapter": null,
566 "publicationDemographic": "shounen",
567 "status": "ongoing",
568 "year": null,
569 "contentRating": "safe",
570 "chapterNumbersResetOnNewVolume": true,
571 "availableTranslatedLanguages": ["en"],
572 "tags": [
573 {
574 "id": tag_id,
575 "type": "tag",
576 "attributes": {
577 "name": {
578 "en": "Action"
579 },
580 "description": [],
581 "group": "genre",
582 "version": 1
583 },
584 "relationships": []
585 }
586 ],
587 "state": "draft",
588 "createdAt": datetime.to_string(),
589 "updatedAt": datetime.to_string(),
590
591 "version": 1
592 },
593 "relationships": []
594 }
595 });
596
597 Mock::given(method("POST"))
598 .and(path("/manga"))
599 .and(header("Authorization", "Bearer sessiontoken"))
600 .and(header("Content-Type", "application/json"))
601 .and(body_json(expected_body))
602 .respond_with(
603 ResponseTemplate::new(201)
604 .insert_header("x-ratelimit-retry-after", "1698723860")
605 .insert_header("x-ratelimit-limit", "40")
606 .insert_header("x-ratelimit-remaining", "39")
607 .set_body_json(response_body),
608 )
609 .expect(1)
610 .mount(&mock_server)
611 .await;
612
613 let res = mangadex_client
614 .manga()
615 .post()
616 .add_title((Language::English, manga_title.clone()))
617 .original_language(Language::Japanese)
618 .last_volume("1")
619 .publication_demographic(Demographic::Shounen)
620 .status(MangaStatus::Ongoing)
621 .content_rating(ContentRating::Safe)
622 .tags(vec![Tag::Action.into()])
623 .version(1_u32)
624 .send()
625 .await?;
626
627 let res = res.body;
628
629 assert_eq!(res.data.attributes.last_volume, Some("1".to_string()));
630
631 Ok(())
632 }
633}