Skip to main content

flix_db/entity/
info.rs

1//! This module contains entities for storing media information such as
2//! titles and overviews.
3
4/// Collection entity.
5pub mod collections {
6	use flix_model::id::CollectionId;
7
8	use sea_orm::entity::prelude::*;
9
10	use crate::entity;
11
12	/// The database representation of a flix collection.
13	#[sea_orm::model]
14	#[derive(Debug, Clone, DeriveEntityModel)]
15	#[sea_orm(table_name = "flix_info_collections")]
16	pub struct Model {
17		/// The collection's ID.
18		#[sea_orm(primary_key, auto_increment = false)]
19		pub id: CollectionId,
20		/// The collection's title.
21		pub title: String,
22		/// The collection's overview.
23		pub overview: String,
24
25		/// The sortable title.
26		#[sea_orm(indexed)]
27		pub sort_title: String,
28		/// The filesystem-safe slug.
29		#[sea_orm(indexed, unique)]
30		pub fs_slug: String,
31		/// The url-safe slug.
32		#[sea_orm(indexed, unique)]
33		pub web_slug: String,
34
35		/// Potential content for this collection.
36		#[sea_orm(has_one)]
37		pub content: HasOne<entity::content::collections::Entity>,
38	}
39
40	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
41	impl ActiveModelBehavior for ActiveModel {}
42}
43
44/// Movie entity.
45pub mod movies {
46	use flix_model::id::MovieId;
47
48	use chrono::NaiveDate;
49	use sea_orm::entity::prelude::*;
50
51	use crate::entity;
52
53	/// The database representation of a flix movie.
54	#[sea_orm::model]
55	#[derive(Debug, Clone, DeriveEntityModel)]
56	#[sea_orm(table_name = "flix_info_movies")]
57	pub struct Model {
58		/// The movie's ID.
59		#[sea_orm(primary_key, auto_increment = false)]
60		pub id: MovieId,
61		/// The movie's title.
62		pub title: String,
63		/// The movie's tagline.
64		pub tagline: String,
65		/// The movie's overview.
66		pub overview: String,
67		/// The movie's release date.
68		#[sea_orm(indexed)]
69		pub date: NaiveDate,
70
71		/// The sortable title.
72		#[sea_orm(indexed)]
73		pub sort_title: String,
74		/// The filesystem-safe slug.
75		#[sea_orm(indexed, unique)]
76		pub fs_slug: String,
77		/// The url-safe slug.
78		#[sea_orm(indexed, unique)]
79		pub web_slug: String,
80
81		/// Potential content for this movie.
82		#[sea_orm(has_one)]
83		pub content: HasOne<entity::content::movies::Entity>,
84	}
85
86	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
87	impl ActiveModelBehavior for ActiveModel {}
88}
89
90/// Show entity.
91pub mod shows {
92	use flix_model::id::ShowId;
93
94	use chrono::NaiveDate;
95	use sea_orm::entity::prelude::*;
96
97	use crate::entity;
98
99	/// The database representation of a flix show.
100	#[sea_orm::model]
101	#[derive(Debug, Clone, DeriveEntityModel)]
102	#[sea_orm(table_name = "flix_info_shows")]
103	pub struct Model {
104		/// The show's ID.
105		#[sea_orm(primary_key, auto_increment = false)]
106		pub id: ShowId,
107		/// The show's title.
108		pub title: String,
109		/// The show's tagline.
110		pub tagline: String,
111		/// The show's overview.
112		pub overview: String,
113		/// The show's air date.
114		#[sea_orm(indexed)]
115		pub date: NaiveDate,
116
117		/// The sortable title.
118		#[sea_orm(indexed)]
119		pub sort_title: String,
120		/// The filesystem-safe slug.
121		#[sea_orm(indexed, unique)]
122		pub fs_slug: String,
123		/// The url-safe slug.
124		#[sea_orm(indexed, unique)]
125		pub web_slug: String,
126
127		/// Seasons that are part of this show.
128		#[sea_orm(has_many)]
129		pub seasons: HasMany<super::seasons::Entity>,
130		/// Episodes that are part of this show.
131		#[sea_orm(has_many)]
132		pub episodes: HasMany<super::episodes::Entity>,
133
134		/// Potential content for this show.
135		#[sea_orm(has_one)]
136		pub content: HasOne<entity::content::shows::Entity>,
137	}
138
139	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
140	impl ActiveModelBehavior for ActiveModel {}
141}
142
143/// Season entity.
144pub mod seasons {
145	use flix_model::id::ShowId;
146	use flix_model::numbers::SeasonNumber;
147
148	use chrono::NaiveDate;
149	use sea_orm::entity::prelude::*;
150
151	use crate::entity;
152
153	/// The database representation of a flix season.
154	#[sea_orm::model]
155	#[derive(Debug, Clone, DeriveEntityModel)]
156	#[sea_orm(table_name = "flix_info_seasons")]
157	pub struct Model {
158		/// The season's show's ID.
159		#[sea_orm(primary_key, auto_increment = false)]
160		pub show_id: ShowId,
161		/// The season's number.
162		#[sea_orm(primary_key, auto_increment = false)]
163		pub season_number: SeasonNumber,
164		/// The season's title.
165		pub title: String,
166		/// The season's overview.
167		pub overview: String,
168		/// The season's air date.
169		#[sea_orm(indexed)]
170		pub date: NaiveDate,
171
172		/// The show this season belongs to.
173		#[sea_orm(
174			belongs_to,
175			from = "show_id",
176			to = "id",
177			on_update = "Cascade",
178			on_delete = "Cascade"
179		)]
180		pub show: HasOne<super::shows::Entity>,
181		/// Episodes that are part of this season.
182		#[sea_orm(has_many)]
183		pub episodes: HasMany<super::episodes::Entity>,
184
185		/// Potential content for this season.
186		#[sea_orm(has_one)]
187		pub content: HasOne<entity::content::seasons::Entity>,
188	}
189
190	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
191	impl ActiveModelBehavior for ActiveModel {}
192}
193
194/// Episode entity.
195pub mod episodes {
196	use flix_model::id::ShowId;
197	use flix_model::numbers::{EpisodeNumber, SeasonNumber};
198
199	use chrono::NaiveDate;
200	use sea_orm::entity::prelude::*;
201
202	use crate::entity;
203
204	/// The database representation of a flix episode.
205	#[sea_orm::model]
206	#[derive(Debug, Clone, DeriveEntityModel)]
207	#[sea_orm(table_name = "flix_info_episodes")]
208	pub struct Model {
209		/// The episode's show's ID.
210		#[sea_orm(primary_key, auto_increment = false)]
211		pub show_id: ShowId,
212		/// The episode's season's number.
213		#[sea_orm(primary_key, auto_increment = false)]
214		pub season_number: SeasonNumber,
215		/// The episode's number.
216		#[sea_orm(primary_key, auto_increment = false)]
217		pub episode_number: EpisodeNumber,
218		/// The episode's title.
219		pub title: String,
220		/// The episode's overview.
221		pub overview: String,
222		/// The episode's air date.
223		#[sea_orm(indexed)]
224		pub date: NaiveDate,
225
226		/// The show this episode belongs to.
227		#[sea_orm(
228			belongs_to,
229			from = "show_id",
230			to = "id",
231			on_update = "Cascade",
232			on_delete = "Cascade"
233		)]
234		pub show: HasOne<super::shows::Entity>,
235		/// The season this episode belongs to.
236		#[sea_orm(
237			belongs_to,
238			from = "(show_id, season_number)",
239			to = "(show_id, season_number)",
240			on_update = "Cascade",
241			on_delete = "Cascade"
242		)]
243		pub season: HasOne<super::seasons::Entity>,
244
245		/// Potential content for this episode.
246		#[sea_orm(has_one)]
247		pub content: HasOne<entity::content::episodes::Entity>,
248	}
249
250	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
251	impl ActiveModelBehavior for ActiveModel {}
252}
253
254/// Macros for creating info entities.
255#[cfg(test)]
256pub mod test {
257	macro_rules! make_info_collection {
258		($db:expr, $id:expr) => {
259			drop(
260				$crate::entity::info::collections::ActiveModel {
261					id: Set(::flix_model::id::CollectionId::from_raw($id)),
262					title: Set(::alloc::string::String::new()),
263					overview: Set(::alloc::string::String::new()),
264					sort_title: Set(::alloc::string::String::new()),
265					fs_slug: Set(format!("C FS {}", $id)),
266					web_slug: Set(format!("C Web {}", $id)),
267				}
268				.insert($db)
269				.await
270				.expect("insert"),
271			);
272		};
273	}
274	pub(crate) use make_info_collection;
275
276	macro_rules! make_info_movie {
277		($db:expr, $id:expr) => {
278			drop(
279				$crate::entity::info::movies::ActiveModel {
280					id: Set(::flix_model::id::MovieId::from_raw($id)),
281					title: Set(::alloc::string::String::new()),
282					tagline: Set(::alloc::string::String::new()),
283					overview: Set(::alloc::string::String::new()),
284					date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
285					sort_title: Set(::alloc::string::String::new()),
286					fs_slug: Set(format!("M FS {}", $id)),
287					web_slug: Set(format!("M Web {}", $id)),
288				}
289				.insert($db)
290				.await
291				.expect("insert"),
292			);
293		};
294	}
295	pub(crate) use make_info_movie;
296
297	macro_rules! make_info_show {
298		($db:expr, $id:expr) => {
299			drop(
300				$crate::entity::info::shows::ActiveModel {
301					id: Set(::flix_model::id::ShowId::from_raw($id)),
302					title: Set(::alloc::string::String::new()),
303					tagline: Set(::alloc::string::String::new()),
304					overview: Set(::alloc::string::String::new()),
305					date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
306					sort_title: Set(::alloc::string::String::new()),
307					fs_slug: Set(format!("S FS {}", $id)),
308					web_slug: Set(format!("S Web {}", $id)),
309				}
310				.insert($db)
311				.await
312				.expect("insert"),
313			);
314		};
315	}
316	pub(crate) use make_info_show;
317
318	macro_rules! make_info_season {
319		($db:expr, $show:expr, $season:expr) => {
320			drop(
321				$crate::entity::info::seasons::ActiveModel {
322					show_id: Set(::flix_model::id::ShowId::from_raw($show)),
323					season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
324					title: Set(::alloc::string::String::new()),
325					overview: Set(::alloc::string::String::new()),
326					date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
327				}
328				.insert($db)
329				.await
330				.expect("insert"),
331			);
332		};
333	}
334	pub(crate) use make_info_season;
335
336	macro_rules! make_info_episode {
337		($db:expr, $show:expr, $season:expr, $episode:expr) => {
338			drop(
339				$crate::entity::info::episodes::ActiveModel {
340					show_id: Set(::flix_model::id::ShowId::from_raw($show)),
341					season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
342					episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
343					title: Set(::alloc::string::String::new()),
344					overview: Set(::alloc::string::String::new()),
345					date: Set(::chrono::NaiveDate::from_yo_opt(1, 1).expect("from_yo_opt")),
346				}
347				.insert($db)
348				.await
349				.expect("insert"),
350			);
351		};
352	}
353	pub(crate) use make_info_episode;
354}
355
356#[cfg(test)]
357mod tests {
358	use flix_model::id::{CollectionId, MovieId, ShowId};
359
360	use chrono::NaiveDate;
361	use sea_orm::ActiveValue::{NotSet, Set};
362	use sea_orm::entity::prelude::*;
363	use sea_orm::sqlx::error::ErrorKind;
364
365	use crate::tests::new_initialized_memory_db;
366
367	use super::super::tests::get_error_kind;
368	use super::super::tests::notsettable;
369	use super::test::{
370		make_info_collection, make_info_episode, make_info_movie, make_info_season, make_info_show,
371	};
372
373	#[cfg(not(miri))]
374	#[tokio::test]
375	#[expect(clippy::missing_panics_doc, reason = "unit test")]
376	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
377	async fn use_test_macros() {
378		let db = new_initialized_memory_db().await;
379
380		make_info_collection!(&db, 1);
381		make_info_movie!(&db, 1);
382		make_info_show!(&db, 1);
383		make_info_season!(&db, 1, 1);
384		make_info_episode!(&db, 1, 1, 1);
385	}
386
387	#[cfg(not(miri))]
388	#[tokio::test]
389	#[expect(clippy::missing_panics_doc, reason = "unit test")]
390	async fn round_trip_collections() {
391		let db = new_initialized_memory_db().await;
392
393		macro_rules! assert_collection {
394			($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => {
395				let model = assert_collection!(@insert, $db, $id $(; $($skip),+)?)
396					.expect("insert");
397
398				assert_eq!(model.id, CollectionId::from_raw($id));
399				assert_eq!(model.title, concat!("C Title ", $id));
400				assert_eq!(model.overview, concat!("C Overview ", $id));
401			};
402			($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => {
403				let model = assert_collection!(@insert, $db, $id $(; $($skip),+)?)
404					.expect_err("insert");
405
406				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
407			};
408			(@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => {
409				super::collections::ActiveModel {
410					id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?),
411					title: notsettable!(title, concat!("C Title ", $id).to_string() $(, $($skip),+)?),
412					overview: notsettable!(overview, concat!("C Overview ", $id).to_string() $(, $($skip),+)?),
413					sort_title: notsettable!(sort_title, concat!("C Sort Title ", $id).to_string() $(, $($skip),+)?),
414					fs_slug: notsettable!(fs_slug, concat!("C FS Slug ", $id).to_string() $(, $($skip),+)?),
415					web_slug: notsettable!(web_slug, concat!("C Web Slug ", $id).to_string() $(, $($skip),+)?),
416				}.insert($db).await
417			};
418		}
419
420		assert_collection!(&db, 1, Success);
421		assert_collection!(&db, 1, UniqueViolation);
422		assert_collection!(&db, 2, Success);
423
424		assert_collection!(&db, 3, Success; id);
425		assert_collection!(&db, 4, NotNullViolation; title);
426		assert_collection!(&db, 5, NotNullViolation; overview);
427		assert_collection!(&db, 6, NotNullViolation; sort_title);
428		assert_collection!(&db, 7, NotNullViolation; fs_slug);
429		assert_collection!(&db, 8, NotNullViolation; web_slug);
430	}
431
432	#[cfg(not(miri))]
433	#[tokio::test]
434	#[expect(clippy::missing_panics_doc, reason = "unit test")]
435	async fn round_trip_movies() {
436		let db = new_initialized_memory_db().await;
437
438		macro_rules! assert_movie {
439			($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => {
440				let model = assert_movie!(@insert, $db, $id $(; $($skip),+)?)
441					.expect("insert");
442
443				assert_eq!(model.id, MovieId::from_raw($id));
444				assert_eq!(model.title, concat!("M Title ", $id));
445				assert_eq!(model.tagline, concat!("M Tagline ", $id));
446				assert_eq!(model.overview, concat!("M Overview ", $id));
447				assert_eq!(model.date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt"));
448			};
449			($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => {
450				let model = assert_movie!(@insert, $db, $id $(; $($skip),+)?)
451					.expect_err("insert");
452
453				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
454			};
455			(@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => {
456				super::movies::ActiveModel {
457					id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?),
458					title: notsettable!(title, concat!("M Title ", $id).to_string() $(, $($skip),+)?),
459					tagline: notsettable!(tagline, concat!("M Tagline ", $id).to_string() $(, $($skip),+)?),
460					overview: notsettable!(overview, concat!("M Overview ", $id).to_string() $(, $($skip),+)?),
461					date: notsettable!(date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?),
462					sort_title: notsettable!(sort_title, concat!("M Sort Title ", $id).to_string() $(, $($skip),+)?),
463					fs_slug: notsettable!(fs_slug, concat!("M FS Slug ", $id).to_string() $(, $($skip),+)?),
464					web_slug: notsettable!(web_slug, concat!("M Web Slug ", $id).to_string() $(, $($skip),+)?),
465				}.insert($db).await
466			};
467		}
468
469		assert_movie!(&db, 1, Success);
470		assert_movie!(&db, 1, UniqueViolation);
471		assert_movie!(&db, 2, Success);
472
473		assert_movie!(&db, 3, Success; id);
474		assert_movie!(&db, 4, NotNullViolation; title);
475		assert_movie!(&db, 5, NotNullViolation; tagline);
476		assert_movie!(&db, 6, NotNullViolation; overview);
477		assert_movie!(&db, 7, NotNullViolation; date);
478		assert_movie!(&db, 8, NotNullViolation; sort_title);
479		assert_movie!(&db, 9, NotNullViolation; fs_slug);
480		assert_movie!(&db, 10, NotNullViolation; web_slug);
481	}
482
483	#[cfg(not(miri))]
484	#[tokio::test]
485	#[expect(clippy::missing_panics_doc, reason = "unit test")]
486	async fn round_trip_shows() {
487		let db = new_initialized_memory_db().await;
488
489		macro_rules! assert_show {
490			($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => {
491				let model = assert_show!(@insert, $db, $id $(; $($skip),+)?)
492					.expect("insert");
493
494				assert_eq!(model.id, ShowId::from_raw($id));
495				assert_eq!(model.title, concat!("S Title ", $id));
496				assert_eq!(model.tagline, concat!("S Tagline ", $id));
497				assert_eq!(model.overview, concat!("S Overview ", $id));
498				assert_eq!(model.date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt"));
499			};
500			($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => {
501				let model = assert_show!(@insert, $db, $id $(; $($skip),+)?)
502					.expect_err("insert");
503
504				assert_eq!(
505					get_error_kind(model).expect("get_error_kind"),
506					ErrorKind::$error
507				);
508			};
509			(@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => {
510				super::shows::ActiveModel {
511					id: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?),
512					title: notsettable!(title, concat!("S Title ", $id).to_string() $(, $($skip),+)?),
513					tagline: notsettable!(tagline, concat!("S Tagline ", $id).to_string() $(, $($skip),+)?),
514					overview: notsettable!(overview, concat!("S Overview ", $id).to_string() $(, $($skip),+)?),
515					date: notsettable!(date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt") $(, $($skip),+)?),
516					sort_title: notsettable!(sort_title, concat!("S Sort Title ", $id).to_string() $(, $($skip),+)?),
517					fs_slug: notsettable!(fs_slug, concat!("S FS Slug ", $id).to_string() $(, $($skip),+)?),
518					web_slug: notsettable!(web_slug, concat!("S Web Slug ", $id).to_string() $(, $($skip),+)?),
519				}.insert($db).await
520			};
521		}
522
523		assert_show!(&db, 1, Success);
524		assert_show!(&db, 1, UniqueViolation);
525		assert_show!(&db, 2, Success);
526
527		assert_show!(&db, 3, Success; id);
528		assert_show!(&db, 4, NotNullViolation; title);
529		assert_show!(&db, 5, NotNullViolation; tagline);
530		assert_show!(&db, 6, NotNullViolation; overview);
531		assert_show!(&db, 7, NotNullViolation; date);
532		assert_show!(&db, 8, NotNullViolation; sort_title);
533		assert_show!(&db, 9, NotNullViolation; fs_slug);
534		assert_show!(&db, 10, NotNullViolation; web_slug);
535	}
536
537	#[cfg(not(miri))]
538	#[tokio::test]
539	#[expect(clippy::missing_panics_doc, reason = "unit test")]
540	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
541	async fn round_trip_seasons() {
542		let db = new_initialized_memory_db().await;
543
544		macro_rules! assert_season {
545			($db:expr, $show:literal, $season:literal, Success $(; $($skip:ident),+)?) => {
546				let model = assert_season!(@insert, $db, $show, $season $(; $($skip),+)?)
547					.expect("insert");
548
549				assert_eq!(model.show_id, ShowId::from_raw($show));
550				assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
551				assert_eq!(model.title, concat!("SS Title ", $show, ",", $season));
552				assert_eq!(model.overview, concat!("SS Overview ", $show, ",", $season));
553				assert_eq!(model.date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt"));
554			};
555			($db:expr, $show:literal, $season:literal, $error:ident $(; $($skip:ident),+)?) => {
556				let model = assert_season!(@insert, $db, $show, $season $(; $($skip),+)?)
557					.expect_err("insert");
558
559				assert_eq!(
560					get_error_kind(model).expect("get_error_kind"),
561					ErrorKind::$error
562				);
563			};
564			(@insert, $db:expr, $show:literal, $season:literal $(; $($skip:ident),+)?) => {
565				super::seasons::ActiveModel {
566					show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?),
567					season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
568					title: notsettable!(title, concat!("SS Title ", $show, ",", $season).to_string() $(, $($skip),+)?),
569					overview: notsettable!(overview, concat!("SS Overview ", $show, ",", $season).to_string() $(, $($skip),+)?),
570					date: notsettable!(date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt") $(, $($skip),+)?),
571				}.insert($db).await
572			};
573		}
574
575		assert_season!(&db, 1, 1, ForeignKeyViolation);
576		make_info_show!(&db, 1);
577		make_info_show!(&db, 2);
578
579		assert_season!(&db, 1, 1, Success);
580		assert_season!(&db, 1, 1, UniqueViolation);
581		assert_season!(&db, 2, 1, Success);
582		assert_season!(&db, 1, 2, Success);
583
584		assert_season!(&db, 1, 3, NotNullViolation; show_id);
585		assert_season!(&db, 1, 4, NotNullViolation; season_number);
586		assert_season!(&db, 1, 5, NotNullViolation; title);
587		assert_season!(&db, 1, 6, NotNullViolation; overview);
588		assert_season!(&db, 1, 7, NotNullViolation; date);
589	}
590
591	#[cfg(not(miri))]
592	#[tokio::test]
593	#[expect(clippy::missing_panics_doc, reason = "unit test")]
594	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
595	async fn round_trip_episodes() {
596		let db = new_initialized_memory_db().await;
597
598		macro_rules! assert_episode {
599			($db:expr, $show:literal, $season:literal, $episode:literal, Success $(; $($skip:ident),+)?) => {
600				let model = assert_episode!(@insert, $db, $show, $season, $episode $(; $($skip),+)?)
601					.expect("insert");
602
603				assert_eq!(model.show_id, ShowId::from_raw($show));
604				assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
605				assert_eq!(model.episode_number, ::flix_model::numbers::EpisodeNumber::new($episode));
606				assert_eq!(model.title, concat!("SSE Title ", $show, ",", $season, ",", $episode));
607				assert_eq!(model.overview, concat!("SSE Overview ", $show, ",", $season, ",", $episode));
608				assert_eq!(model.date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt"));
609			};
610			($db:expr, $show:literal, $season:literal, $episode:literal, $error:ident $(; $($skip:ident),+)?) => {
611				let model = assert_episode!(@insert, $db, $show, $season, $episode $(; $($skip),+)?)
612					.expect_err("insert");
613
614				assert_eq!(
615					get_error_kind(model).expect("get_error_kind"),
616					ErrorKind::$error
617				);
618			};
619			(@insert, $db:expr, $show:literal, $season:literal, $episode:literal $(; $($skip:ident),+)?) => {
620				super::episodes::ActiveModel {
621					show_id: notsettable!(show_id, ShowId::from_raw($show) $(, $($skip),+)?),
622					season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
623					episode_number: notsettable!(episode_number, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
624					title: notsettable!(title, concat!("SSE Title ", $show, ",", $season, ",", $episode).to_string() $(, $($skip),+)?),
625					overview: notsettable!(overview, concat!("SSE Overview ", $show, ",", $season, ",", $episode).to_string() $(, $($skip),+)?),
626					date: notsettable!(date, NaiveDate::from_yo_opt($show + $season, 1).expect("from_yo_opt") $(, $($skip),+)?),
627				}.insert($db).await
628			};
629		}
630
631		assert_episode!(&db, 1, 1, 1, ForeignKeyViolation);
632		make_info_show!(&db, 1);
633		make_info_show!(&db, 2);
634		assert_episode!(&db, 1, 1, 1, ForeignKeyViolation);
635		make_info_season!(&db, 1, 1);
636		make_info_season!(&db, 1, 2);
637		make_info_season!(&db, 2, 1);
638
639		assert_episode!(&db, 1, 1, 1, Success);
640		assert_episode!(&db, 1, 1, 1, UniqueViolation);
641		assert_episode!(&db, 2, 1, 1, Success);
642		assert_episode!(&db, 1, 2, 1, Success);
643		assert_episode!(&db, 1, 1, 2, Success);
644
645		assert_episode!(&db, 1, 1, 3, NotNullViolation; show_id);
646		assert_episode!(&db, 1, 1, 4, NotNullViolation; season_number);
647		assert_episode!(&db, 1, 1, 4, NotNullViolation; episode_number);
648		assert_episode!(&db, 1, 1, 5, NotNullViolation; title);
649		assert_episode!(&db, 1, 1, 6, NotNullViolation; overview);
650		assert_episode!(&db, 1, 1, 7, NotNullViolation; date);
651	}
652}