use super::*; struct TestDirectory(PathBuf); impl TestDirectory { fn new() -> Self { loop { let path = std::env::temp_dir().join(format!( "tubular-watch-test-{}-{}", std::process::id(), NEXT_NAME.fetch_add(1, Ordering::Relaxed) )); match create_private_directory(&path) { Ok(()) => return Self(path), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => panic!("{e}"), } } } fn config(&self) -> PathBuf { self.0.join("config.toml") } } impl Drop for TestDirectory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn video() -> Video { Video { id: "jNQXAC9IVRw".into(), title: "Me at the zoo".into(), channel: Some("jawed".into()), channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()), uploader_id: Some("@jawed".into()), timestamp: Some(1_114_300_000), publication_date: Some("2005-04-23".into()), availability: None, } } fn append(path: &Path, bytes: &[u8]) { let mut options = OpenOptions::new(); options.write(true).append(true).create(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } options.open(path).unwrap().write_all(bytes).unwrap(); } fn record(id: &str, event: &str, eligible: bool, position: f64) -> String { format!( "{{\"version\":1,\"event\":\"{event}\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":{eligible}}}\n" ) } fn miruro_video(episode: u32) -> Video { Video { id: Identity::new("opaque_Show-1".into(), episode) .unwrap() .key(), title: format!("Example show ยท Episode {episode}"), channel: Some("Miruro".into()), channel_id: None, uploader_id: None, timestamp: Some(1_114_300_000), publication_date: Some("2005-04-23".into()), availability: None, } } fn show() -> crate::miruro_browser::Media { crate::miruro_browser::Media { id: "opaque_Show-1".into(), title: "Example show".into(), metadata: serde_json::json!({ "id": "opaque_Show-1", "title": {"english": "Example show", "native": null}, "episode_counts": {"sub": 12, "dub": null}, "genres": ["Adventure", "Fantasy"], }), } } #[test] fn miruro_library_round_trips_overlapping_tags_and_keeps_episode_state_independent() { let directory = TestDirectory::new(); let path = directory.0.join("miruro-library.toml"); let mut state = State::open(&directory.config()).unwrap(); assert!(state.shows().is_empty()); assert!(!path.exists()); let episode = miruro_video(1); for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { state.save(kind, &episode).unwrap(); } state .set_watch_status(&episode, WatchStatus::Watched) .unwrap(); let episode_files = ["saved-videos.toml", "watch-later.toml", "watch-state.toml"] .map(|name| (name, fs::read(directory.0.join(name)).unwrap())); let mut first = show(); let mut second = show(); second.id = "another_Show".into(); second.title = "Another show".into(); second.metadata = serde_json::Value::Null; state .save_show( &first, &[" Alpha ".into(), "Alpha".into(), "Beta".into()].into(), ) .unwrap(); state .save_show(&second, &["Beta".into(), "Gamma".into()].into()) .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600 ); } let mut state = State::open(&directory.config()).unwrap(); assert_eq!(state.shows().len(), 2); assert_eq!(state.shows()[0].media.metadata, first.metadata); assert_eq!( state.shows()[0].tags, ["Alpha".into(), "Beta".into()].into() ); assert_eq!( state.shows()[1].tags, ["Beta".into(), "Gamma".into()].into() ); assert!(state.shows()[1].media.metadata.is_null()); first.title = "Updated show title".into(); first.metadata["title"]["english"] = serde_json::json!("Updated show title"); state.save_show(&first, &BTreeSet::new()).unwrap(); state.save_show(&first, &BTreeSet::new()).unwrap(); let mut state = State::open(&directory.config()).unwrap(); assert_eq!(state.shows().len(), 2); assert_eq!(state.shows()[0].media.title, first.title); assert_eq!(state.shows()[0].media.metadata, first.metadata); assert!(state.shows()[0].tags.is_empty()); state.remove_show(&first.id).unwrap(); state.remove_show(&first.id).unwrap(); let state = State::open(&directory.config()).unwrap(); assert_eq!(state.shows().len(), 1); assert_eq!(state.shows()[0].media.id, second.id); assert!(state.is_watched(&episode.id)); for (name, bytes) in episode_files { assert_eq!(fs::read(directory.0.join(name)).unwrap(), bytes); } // Duplicate hand-edited records behave like the existing episode collections. let document = fs::read_to_string(&path).unwrap(); let duplicate = document.split_once("[[shows]]").unwrap().1; append(&path, format!("\n[[shows]]{duplicate}").as_bytes()); let mut state = State::open(&directory.config()).unwrap(); assert_eq!(state.shows().len(), 1); state.save_show(&second, &BTreeSet::new()).unwrap(); assert_eq!(State::open(&directory.config()).unwrap().shows().len(), 1); state.remove_show(&second.id).unwrap(); assert!(State::open(&directory.config()).unwrap().shows().is_empty()); } #[test] fn miruro_library_rejects_invalid_inputs_and_files_without_changing_saved_state() { let directory = TestDirectory::new(); let path = directory.0.join("miruro-library.toml"); let mut state = State::open(&directory.config()).unwrap(); let media = show(); state .save_show(&media, &["Original".into()].into()) .unwrap(); let original = fs::read(&path).unwrap(); for (field, value) in [ ("id", "https://unsafe.example/show".to_owned()), ("title", " ".to_owned()), ("title", "bad\nshow".to_owned()), ("title", "s".repeat(MAX_LINE + 1)), ("metadata", "m".repeat(MAX_SHOW_METADATA_BYTES)), ] { let mut invalid_media = media.clone(); match field { "id" => invalid_media.id = value, "title" => invalid_media.title = value, _ => invalid_media.metadata = serde_json::Value::String(value), } assert!(state.save_show(&invalid_media, &BTreeSet::new()).is_err()); assert_eq!(state.shows()[0].media.title, media.title); assert_eq!(state.shows()[0].media.metadata, media.metadata); assert_eq!(fs::read(&path).unwrap(), original); } for tags in [ [" ".into()].into(), ["bad\ttag".into()].into(), ["t".repeat(MAX_TAG_BYTES + 1)].into(), (0..=MAX_SHOW_TAGS).map(|index| index.to_string()).collect(), ] { assert!(state.save_show(&media, &tags).is_err()); assert_eq!(state.shows()[0].tags, ["Original".into()].into()); assert_eq!(fs::read(&path).unwrap(), original); } assert!(state.remove_show("https://unsafe.example/show").is_err()); assert_eq!(fs::read(&path).unwrap(), original); let valid = std::str::from_utf8(&original).unwrap(); let mut invalid_files = vec![ "not = [valid".to_owned(), "version = 2\nshows = []".to_owned(), "version = 1\nunexpected = true".to_owned(), "version = 1\nshows = [1]".to_owned(), "version = 1\n[[shows]]\nid = 'stable'\ntitle = 'Title'\nmetadata = '{'\ntags = []" .to_owned(), ]; let mut invalid_tags = Array::new(); invalid_tags.push(1); let mut blank_tag = Array::new(); blank_tag.push(" "); let mut excessive_tags = Array::new(); for _ in 0..=MAX_SHOW_TAGS { excessive_tags.push("Tag"); } for (field, item) in [ ("tags", value(invalid_tags)), ("tags", value(blank_tag)), ("tags", value(excessive_tags)), ("id", value("../unsafe")), ("title", value("")), ("title", value("s".repeat(MAX_LINE + 1))), ( "metadata", value(format!("\"{}\"", "m".repeat(MAX_SHOW_METADATA_BYTES))), ), ("unknown", value(true)), ] { let mut document = valid.parse::().unwrap(); document["shows"] .as_array_of_tables_mut() .unwrap() .get_mut(0) .unwrap()[field] = item; invalid_files.push(document.to_string()); } for contents in invalid_files { fs::write(&path, &contents).unwrap(); assert!(State::open(&directory.config()).is_err(), "{contents}"); assert_eq!(fs::read_to_string(&path).unwrap(), contents); } } #[test] fn miruro_library_does_not_overwrite_external_creations_or_edits() { let directory = TestDirectory::new(); let path = directory.0.join("miruro-library.toml"); let mut state = State::open(&directory.config()).unwrap(); let media = show(); fs::write(&path, "external creation").unwrap(); assert!(state.save_show(&media, &BTreeSet::new()).is_err()); assert!(state.shows().is_empty()); assert_eq!(fs::read_to_string(&path).unwrap(), "external creation"); fs::remove_file(&path).unwrap(); state .save_show(&media, &["Original".into()].into()) .unwrap(); append(&path, b"\n# external edit\n"); let changed = fs::read(&path).unwrap(); let mut updated = media.clone(); updated.title = "Replacement".into(); assert!(state.save_show(&updated, &["New".into()].into()).is_err()); assert!(state.remove_show(&media.id).is_err()); assert!(state.remove_show("missing_Show").is_err()); assert_eq!(state.shows().len(), 1); assert_eq!(state.shows()[0].media.title, media.title); assert_eq!(state.shows()[0].tags, ["Original".into()].into()); assert_eq!(fs::read(&path).unwrap(), changed); } fn miruro_source(video: &Video) -> SourceDocument { SourceDocument { version: 1, url: "https://cdn.example/video.m3u8?token=private-token".into(), headers: [("Referer".into(), "https://player.example/".into())].into(), identity: Identity::from_key(&video.id).unwrap(), title: Some(video.title.clone()), subtitles: Vec::new(), } } fn miruro_record( video: &Video, event: &str, eligible: bool, position: f64, duration: Option, ) -> String { serde_json::json!({ "version": 2, "event": event, "media_identity": Identity::from_key(&video.id).unwrap(), "position_seconds": position, "duration_seconds": duration, "completion_eligible": eligible, }) .to_string() + "\n" } #[test] fn manual_status_transitions_preserve_both_providers_and_other_collections() { for video in [video(), miruro_video(1)] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let other = miruro_video(2); state .set_watch_status(&other, WatchStatus::Watched) .unwrap(); for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { state.save(kind, &video).unwrap(); } let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap(); let later = fs::read(directory.0.join("watch-later.toml")).unwrap(); for status in [ WatchStatus::Watching, WatchStatus::Watched, WatchStatus::Watching, ] { state.set_watch_status(&video, status).unwrap(); let watching = fs::read(directory.0.join("currently-watching.toml")).unwrap(); let watched = read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(); state.set_watch_status(&video, status).unwrap(); assert_eq!( fs::read(directory.0.join("currently-watching.toml")).unwrap(), watching ); assert_eq!( read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(), watched ); state = State::open(&directory.config()).unwrap(); assert!(state.is_watched(&other.id)); assert_eq!( state.is_watched(&video.id), matches!(status, WatchStatus::Watched) ); assert_eq!( state.contains(CollectionKind::Watching, &video.id), matches!(status, WatchStatus::Watching) ); if matches!(status, WatchStatus::Watching) { assert_eq!( serde_json::to_value(&state.videos(CollectionKind::Watching)[0]).unwrap(), serde_json::to_value(&video).unwrap() ); } assert_eq!( fs::read(directory.0.join("saved-videos.toml")).unwrap(), saved ); assert_eq!( fs::read(directory.0.join("watch-later.toml")).unwrap(), later ); } } } #[test] fn manual_watching_ignores_old_completion_replays_but_accepts_new_sessions() { for video in [video(), miruro_video(1)] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let completion = if video.id.starts_with("miruro:") { miruro_record(&video, "ended", true, 95.0, Some(100.0)) } else { record(&video.id, "ended", true, 95.0) }; let first = state.progress_file(&video).unwrap(); register_identity(&first, &video.id).unwrap(); append(&first, completion.as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); let second = state.progress_file(&video).unwrap(); register_identity(&second, &video.id).unwrap(); // Neither the reports nor the second session have been read by this instance. drop(state); let mut state = State::open(&directory.config()).unwrap(); state .set_watch_status(&video, WatchStatus::Watching) .unwrap(); for report in [&first, &second] { append(report, completion.as_bytes()); } drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); assert!(!state.is_watched(&video.id)); assert!(state.contains(CollectionKind::Watching, &video.id)); let new_session = state.progress_file(&video).unwrap(); register_identity(&new_session, &video.id).unwrap(); append(&new_session, completion.as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); assert!(!state.contains(CollectionKind::Watching, &video.id)); } } #[test] fn manual_status_rejects_invalid_videos_and_external_edits_before_writing() { for video in [video(), miruro_video(1)] { for status in [WatchStatus::Watching, WatchStatus::Watched] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let mut invalid_video = video.clone(); invalid_video.id = "--invalid".into(); assert!(state.set_watch_status(&invalid_video, status).is_err()); assert!(!directory.0.join("watch-state.toml").exists()); assert!(!directory.0.join("currently-watching.toml").exists()); state .set_watch_status(&video, WatchStatus::Watching) .unwrap(); state .set_watch_status(&video, WatchStatus::Watched) .unwrap(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); for name in ["watch-state.toml", "currently-watching.toml"] { let path = directory.0.join(name); let original = fs::read(&path).unwrap(); append(&path, b"\n# external edit\n"); let changed = fs::read(&path).unwrap(); assert!(state.set_watch_status(&video, status).is_err()); assert_eq!(fs::read(&path).unwrap(), changed); assert!(state.is_watched(&video.id)); assert!(!state.contains(CollectionKind::Watching, &video.id)); assert!(!report.with_file_name("watching-seen.toml").exists()); fs::write(path, original).unwrap(); } } } } #[test] fn manual_watching_clears_consumed_pending_completion_and_checks_session_edits() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); let watched = directory.0.join("watch-state.toml"); fs::write(&watched, "external edit").unwrap(); assert!(state.poll_progress().is_err()); assert!(state.pending.contains(&video.id)); fs::remove_file(watched).unwrap(); let marker = report.with_file_name("watching-seen.toml"); fs::write(&marker, "external edit").unwrap(); assert!( state .set_watch_status(&video, WatchStatus::Watching) .is_err() ); assert!(state.videos(CollectionKind::Watching).is_empty()); assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit"); fs::remove_file(&marker).unwrap(); state .set_watch_status(&video, WatchStatus::Watching) .unwrap(); assert!(!state.pending.contains(&video.id)); state.poll_progress().unwrap(); assert!(!state.is_watched(&video.id)); assert!(state.contains(CollectionKind::Watching, &video.id)); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); assert!(!state.is_watched(&video.id)); assert!(state.contains(CollectionKind::Watching, &video.id)); } #[test] fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { state.save(kind, &video).unwrap(); state.save(kind, &video).unwrap(); assert_eq!(state.videos(kind).len(), 1); assert!(state.contains(kind, &video.id)); } let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); append( &report, record(&video.id, "progress", true, 95.0).as_bytes(), ); assert!(state.poll_progress().unwrap()); assert!(!state.poll_progress().unwrap()); let mut restarted = State::open(&directory.config()).unwrap(); for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { let stored = &restarted.videos(kind)[0]; assert_eq!(stored.id, video.id); assert_eq!(stored.title, video.title); assert_eq!(stored.channel, video.channel); assert_eq!(stored.channel_id, video.channel_id); assert_eq!(stored.uploader_id, video.uploader_id); assert_eq!(stored.timestamp, video.timestamp); assert_eq!(stored.publication_date, video.publication_date); } assert!(restarted.is_watched(&video.id)); restarted .remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id)) .unwrap(); restarted .remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id)) .unwrap(); let mut restarted = State::open(&directory.config()).unwrap(); assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); assert!(restarted.contains(CollectionKind::Saved, &video.id)); assert!(restarted.is_watched(&video.id)); restarted.save(CollectionKind::WatchLater, &video).unwrap(); restarted .remove(CollectionKind::Saved, std::slice::from_ref(&video.id)) .unwrap(); let mut restarted = State::open(&directory.config()).unwrap(); assert!(restarted.videos(CollectionKind::Saved).is_empty()); assert!(restarted.contains(CollectionKind::WatchLater, &video.id)); assert!(restarted.is_watched(&video.id)); assert!(!restarted.poll_progress().unwrap()); } #[test] fn malformed_and_externally_changed_state_is_never_overwritten() { let directory = TestDirectory::new(); let path = directory.0.join("watch-later.toml"); let mut state = State::open(&directory.config()).unwrap(); fs::write(&path, "not = [valid").unwrap(); assert!(state.save(CollectionKind::WatchLater, &video()).is_err()); assert!(state.videos(CollectionKind::WatchLater).is_empty()); assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid"); assert!(State::open(&directory.config()).is_err()); fs::remove_file(&path).unwrap(); let saved_path = directory.0.join("saved-videos.toml"); fs::write(&saved_path, "not = [valid").unwrap(); assert!(state.save(CollectionKind::Saved, &video()).is_err()); assert!(state.videos(CollectionKind::Saved).is_empty()); assert_eq!(fs::read_to_string(&saved_path).unwrap(), "not = [valid"); assert!(State::open(&directory.config()).is_err()); fs::remove_file(&saved_path).unwrap(); fs::write(&saved_path, "version = 1\n[[videos]]\nid = '--unsafe'\n").unwrap(); assert!(State::open(&directory.config()).is_err()); assert_eq!( fs::read_to_string(&saved_path).unwrap(), "version = 1\n[[videos]]\nid = '--unsafe'\n" ); fs::remove_file(&saved_path).unwrap(); fs::write( directory.0.join("watch-state.toml"), "version = 1\nwatched = [\"--unsafe\"]\n", ) .unwrap(); assert!(State::open(&directory.config()).is_err()); assert_eq!( fs::read_to_string(directory.0.join("watch-state.toml")).unwrap(), "version = 1\nwatched = [\"--unsafe\"]\n" ); } #[test] fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let report = state.progress_file(&video()).unwrap(); register_identity(&report, &video().id).unwrap(); append( &report, record(&video().id, "progress", true, 95.0).as_bytes(), ); let path = directory.0.join("watch-state.toml"); fs::write(&path, "malformed state").unwrap(); assert!(state.poll_progress().is_err()); assert!(!state.is_watched(&video().id)); assert!(state.videos(CollectionKind::Watching).is_empty()); assert_eq!(fs::read_to_string(&path).unwrap(), "malformed state"); fs::remove_file(&path).unwrap(); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video().id)); assert!(!state.poll_progress().unwrap()); } #[test] fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let report = state.progress_file(&video()).unwrap(); register_identity(&report, &video().id).unwrap(); let line = record(&video().id, "ended", true, 95.0); append(&report, &line.as_bytes()[..line.len() - 1]); assert!(!state.poll_progress().unwrap()); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); let mut other = video(); other.id = "rQ_J9WH6CGk".into(); let bad = state.progress_file(&other).unwrap(); register_identity(&bad, &other.id).unwrap(); append(&bad, b"not json\n"); append(&report, b"\n"); assert!(state.poll_progress().is_err()); assert!(state.is_watched(&video().id)); assert!(state.poll_progress().unwrap()); assert!(!state.poll_progress().unwrap()); } #[test] fn wrong_identity_ineligible_positions_and_untrusted_reports_do_not_mark_watched() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let report = state.progress_file(&video()).unwrap(); register_identity(&report, &video().id).unwrap(); for line in [ record("rQ_J9WH6CGk", "progress", true, 100.0), record(&video().id, "progress", false, 100.0), record(&video().id, "closed", true, 94.9), ] { append(&report, line.as_bytes()); } assert!(state.poll_progress().unwrap()); assert!(state.contains(CollectionKind::Watching, &video().id)); assert!(!state.is_watched(&video().id)); assert!(!state.is_watched("rQ_J9WH6CGk")); for line in [ record(&video().id, "unknown", true, 100.0), record(&video().id, "progress", true, -1.0), record(&video().id, "progress", true, 100.0).replace("\"version\":1", "\"version\":2"), record(&video().id, "progress", true, 100.0) .replace("\"duration_seconds\":100", "\"duration_seconds\":0"), ] { append(&report, line.as_bytes()); assert!(state.poll_progress().is_err()); assert!(!state.is_watched(&video().id)); assert!(!state.poll_progress().unwrap()); } #[cfg(unix)] { let symlink = state.progress_file(&video()).unwrap(); register_identity(&symlink, &video().id).unwrap(); let external = directory.0.join("external.jsonl"); append( &external, record(&video().id, "progress", true, 100.0).as_bytes(), ); std::os::unix::fs::symlink(&external, &symlink).unwrap(); assert!(state.poll_progress().is_err()); assert!(!state.is_watched(&video().id)); assert!(!state.poll_progress().unwrap()); } } #[test] fn watching_requires_a_valid_positive_bound_acknowledgement() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.poll_progress().unwrap()); register_identity(&report, &video.id).unwrap(); assert!(!state.poll_progress().unwrap()); for line in [ record("rQ_J9WH6CGk", "progress", true, 100.0), record(&video.id, "progress", true, 0.0), ] { append(&report, line.as_bytes()); assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); } for line in [ record(&video.id, "unknown", true, 10.0), record(&video.id, "progress", true, -1.0), record(&video.id, "progress", true, 10.0) .replace("\"duration_seconds\":100", "\"duration_seconds\":0"), record(&video.id, "progress", true, 10.0).replace("\"version\":1", "\"version\":2"), ] { append(&report, line.as_bytes()); assert!(state.poll_progress().is_err()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&video.id)); } append( &report, record(&video.id, "progress", false, 10.0).as_bytes(), ); assert!(state.poll_progress().unwrap()); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!(!state.is_watched(&video.id)); } #[test] fn starts_arriving_while_closed_preserve_metadata_without_a_creator() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let mut video = video(); video.channel = None; video.channel_id = None; video.uploader_id = None; assert!(state.save(CollectionKind::Saved, &video).is_err()); assert!(state.save(CollectionKind::WatchLater, &video).is_err()); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); drop(state); append( &report, record(&video.id, "closed", true, 10.0) .replace("\"duration_seconds\":100", "\"duration_seconds\":null") .as_bytes(), ); let mut state = State::open(&directory.config()).unwrap(); assert!(state.poll_progress().unwrap()); let stored = &state.videos(CollectionKind::Watching)[0]; assert_eq!(stored.id, video.id); assert_eq!(stored.title, video.title); assert_eq!(stored.channel, None); assert_eq!(stored.channel_id, None); assert_eq!(stored.uploader_id, None); assert_eq!(stored.publication_date, video.publication_date); assert_eq!(stored.timestamp, video.timestamp); append(&report, record(&video.id, "ended", false, 100.0).as_bytes()); assert!(!state.poll_progress().unwrap()); assert!(!state.is_watched(&video.id)); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!(!state.poll_progress().unwrap()); } #[test] fn completion_wins_over_parallel_sessions_late_reports_and_replays() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { state.save(kind, &video).unwrap(); } let first = state.progress_file(&video).unwrap(); register_identity(&first, &video.id).unwrap(); append(&first, record(&video.id, "progress", true, 94.9).as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!(!state.is_watched(&video.id)); let second = state.progress_file(&video).unwrap(); register_identity(&second, &video.id).unwrap(); append( &second, record(&video.id, "progress", true, 20.0).as_bytes(), ); append(&first, record(&video.id, "ended", true, 95.0).as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); assert!(!state.contains(CollectionKind::Watching, &video.id)); for report in [&first, &second] { append(report, record(&video.id, "progress", true, 30.0).as_bytes()); } let replay = state.progress_file(&video).unwrap(); register_identity(&replay, &video.id).unwrap(); append( &replay, record(&video.id, "progress", true, 10.0).as_bytes(), ); assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); state .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) .unwrap(); assert!(state.is_watched(&video.id)); assert!(state.contains(CollectionKind::Saved, &video.id)); assert!(state.contains(CollectionKind::WatchLater, &video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); } #[test] fn manual_removal_suppresses_existing_sessions_across_restart_but_not_new_sessions() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { state.save(kind, &video).unwrap(); } let first = state.progress_file(&video).unwrap(); register_identity(&first, &video.id).unwrap(); append(&first, record(&video.id, "progress", true, 10.0).as_bytes()); assert!(state.poll_progress().unwrap()); // Simulate a crash after collection persistence but before its admission marker. fs::remove_file(first.with_file_name("watching-seen.toml")).unwrap(); let second = state.progress_file(&video).unwrap(); register_identity(&second, &video.id).unwrap(); append( &second, record(&video.id, "progress", true, 20.0).as_bytes(), ); drop(state); let mut state = State::open(&directory.config()).unwrap(); state .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) .unwrap(); for report in [&first, &second] { append(report, record(&video.id, "progress", true, 50.0).as_bytes()); } assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&video.id)); assert!(state.contains(CollectionKind::Saved, &video.id)); assert!(state.contains(CollectionKind::WatchLater, &video.id)); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); let replay = state.progress_file(&video).unwrap(); register_identity(&replay, &video.id).unwrap(); append( &replay, record(&video.id, "progress", true, 10.0).as_bytes(), ); assert!(state.poll_progress().unwrap()); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!(!state.is_watched(&video.id)); } #[test] fn watching_write_conflicts_retry_consumed_starts() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); let path = directory.0.join("currently-watching.toml"); fs::write(&path, "external edit").unwrap(); append( &report, record(&video.id, "progress", true, 10.0).as_bytes(), ); assert!(state.poll_progress().is_err()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert_eq!(fs::read_to_string(&path).unwrap(), "external edit"); assert!(state.poll_progress().is_err()); fs::remove_file(&path).unwrap(); assert!(state.poll_progress().unwrap()); assert!(state.contains(CollectionKind::Watching, &video.id)); let original = fs::read(&path).unwrap(); append(&path, b"\n# external removal conflict\n"); let replay = state.progress_file(&video).unwrap(); register_identity(&replay, &video.id).unwrap(); append( &replay, record(&video.id, "progress", true, 20.0).as_bytes(), ); assert!(state.poll_progress().is_err()); assert!(!replay.with_file_name("watching-seen.toml").exists()); fs::write(&path, original).unwrap(); assert!(!state.poll_progress().unwrap()); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!(!state.poll_progress().unwrap()); } #[test] fn failed_session_admission_retries_without_losing_starts_or_allowing_readdition() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); state.streams[0].prepare().unwrap(); let marker = report.with_file_name("watching-seen.toml"); append(&marker, b"external edit"); append( &report, record(&video.id, "progress", true, 10.0).as_bytes(), ); assert!(state.poll_progress().is_err()); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!( state .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) .is_err() ); assert!(state.contains(CollectionKind::Watching, &video.id)); assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit"); fs::remove_file(&marker).unwrap(); assert!(state.poll_progress().unwrap()); state .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) .unwrap(); append( &report, record(&video.id, "progress", true, 50.0).as_bytes(), ); assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); } #[test] fn failed_completion_cleanup_retries_and_restart_prunes_stale_watching() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); append( &report, record(&video.id, "progress", true, 10.0).as_bytes(), ); assert!(state.poll_progress().unwrap()); let path = directory.0.join("currently-watching.toml"); let original = fs::read(&path).unwrap(); append(&path, b"\n# external edit\n"); let changed = fs::read(&path).unwrap(); append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); assert!(state.poll_progress().is_err()); assert!(state.is_watched(&video.id)); assert!(state.contains(CollectionKind::Watching, &video.id)); assert_eq!(fs::read(&path).unwrap(), changed); append( &report, record(&video.id, "progress", true, 20.0).as_bytes(), ); assert!(state.poll_progress().is_err()); fs::write(&path, original).unwrap(); assert!(state.poll_progress().unwrap()); assert!(!state.contains(CollectionKind::Watching, &video.id)); drop(state); // Crash recovery must also handle a watched write committed before collection cleanup. fs::write(&path, video_document(std::iter::once(&video)).to_string()).unwrap(); let mut state = State::open(&directory.config()).unwrap(); assert!(state.is_watched(&video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.poll_progress().unwrap()); } #[test] fn legacy_sessions_use_saved_metadata_when_available_and_still_mark_completion_without_it() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); state.save(CollectionKind::Saved, &video).unwrap(); let report = state.progress_file(&video).unwrap(); fs::remove_file(report.with_file_name("video.toml")).unwrap(); register_identity(&report, &video.id).unwrap(); append( &report, record(&video.id, "progress", true, 10.0).as_bytes(), ); assert!(state.poll_progress().unwrap()); assert!(state.contains(CollectionKind::Watching, &video.id)); append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); let mut other = video.clone(); other.id = "rQ_J9WH6CGk".into(); let report = state.progress_file(&other).unwrap(); fs::remove_file(report.with_file_name("video.toml")).unwrap(); register_identity(&report, &other.id).unwrap(); append( &report, record(&other.id, "progress", true, 10.0).as_bytes(), ); assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); append(&report, record(&other.id, "closed", true, 95.0).as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&other.id)); } #[test] fn session_metadata_and_admission_markers_must_match_the_bound_identity() { for mismatched_snapshot in [true, false] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); if mismatched_snapshot { let mut other = video.clone(); other.id = "rQ_J9WH6CGk".into(); fs::write( report.with_file_name("video.toml"), video_document(std::iter::once(&other)).to_string(), ) .unwrap(); } else { append( &report.with_file_name("watching-seen.toml"), b"version = 1\nid = \"rQ_J9WH6CGk\"\n", ); } append(&report, record(&video.id, "ended", true, 100.0).as_bytes()); assert!(state.poll_progress().is_err()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&video.id)); } } #[cfg(unix)] #[test] fn session_snapshots_reject_symlinks_and_shared_permissions() { use std::os::unix::fs::PermissionsExt; for symlink in [true, false] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); let snapshot = report.with_file_name("video.toml"); if symlink { let external = directory.0.join("external.toml"); fs::write(&external, fs::read(&snapshot).unwrap()).unwrap(); fs::remove_file(&snapshot).unwrap(); std::os::unix::fs::symlink(&external, &snapshot).unwrap(); } else { fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o644)).unwrap(); } append( &report, record(&video.id, "progress", true, 100.0).as_bytes(), ); assert!(state.poll_progress().is_err()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&video.id)); } } #[test] fn watching_and_session_snapshots_keep_date_handle_channel_and_schema_validation() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); for invalid_field in ["date", "handle", "channel"] { let mut video = video(); match invalid_field { "date" => video.publication_date = Some("2005-02-30".into()), "handle" => video.uploader_id = Some("not-a-handle".into()), "channel" => video.channel_id = Some("not-a-channel-id".into()), _ => unreachable!(), } assert!(state.progress_file(&video).is_err()); assert!(state.save(CollectionKind::Watching, &video).is_err()); assert!(state.videos(CollectionKind::Watching).is_empty()); } let path = directory.0.join("currently-watching.toml"); let contents = "version = 1\nvideos = []\nunexpected = true\n"; fs::write(&path, contents).unwrap(); assert!(State::open(&directory.config()).is_err()); assert_eq!(fs::read_to_string(&path).unwrap(), contents); } #[test] fn watching_and_session_snapshots_reject_oversized_files() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); OpenOptions::new() .write(true) .open(report.with_file_name("video.toml")) .unwrap() .set_len(MAX_STATE_BYTES + 1) .unwrap(); append( &report, record(&video.id, "progress", true, 100.0).as_bytes(), ); assert!(state.poll_progress().is_err()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&video.id)); File::create(directory.0.join("currently-watching.toml")) .unwrap() .set_len(MAX_STATE_BYTES + 1) .unwrap(); assert!(State::open(&directory.config()).is_err()); } #[test] fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_in_state() { let directory = TestDirectory::new(); let video = miruro_video(1); let mut state = State::open(&directory.config()).unwrap(); for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { state.save(kind, &video).unwrap(); } let source = miruro_source(&video); let (source_file, report) = state.miruro_session(&video, &source).unwrap(); let source_bytes = fs::read(&source_file).unwrap(); assert_eq!(source_bytes, serde_json::to_vec(&source).unwrap()); assert!(!report.exists()); assert_eq!( fs::read_to_string(report.with_file_name("video-id")).unwrap(), video.id ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; for path in [ &source_file, &report.with_file_name("video-id"), &report.with_file_name("video.toml"), ] { assert_eq!( fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600 ); } for path in [ report.parent().unwrap(), &directory.0.join("playback-sessions"), ] { assert_eq!( fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o700 ); } } assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); drop(state); // A GUI can acknowledge its final snapshot after Tubular has quit. append( &report, miruro_record(&video, "closed", false, 10.0, None).as_bytes(), ); let mut state = State::open(&directory.config()).unwrap(); assert!(state.poll_progress().unwrap()); let stored = &state.videos(CollectionKind::Watching)[0]; assert_eq!(stored.id, video.id); assert_eq!(stored.title, video.title); assert_eq!(stored.channel, video.channel); assert_eq!(stored.timestamp, video.timestamp); assert_eq!(stored.publication_date, video.publication_date); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(state.contains(CollectionKind::Watching, &video.id)); assert!(!state.poll_progress().unwrap()); append( &report, miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), ); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(state.is_watched(&video.id)); assert!(!state.poll_progress().unwrap()); for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { let stored = &state.videos(kind)[0]; assert_eq!(stored.id, video.id); assert_eq!(stored.title, video.title); assert_eq!(stored.channel, video.channel); assert_eq!(stored.channel_id, None); assert_eq!(stored.uploader_id, None); assert_eq!(stored.timestamp, video.timestamp); assert_eq!(stored.publication_date, video.publication_date); } for path in [ directory.0.join("watch-later.toml"), directory.0.join("saved-videos.toml"), directory.0.join("currently-watching.toml"), directory.0.join("watch-state.toml"), report.with_file_name("video.toml"), ] { let contents = fs::read_to_string(path).unwrap(); assert!(!contents.contains("private-token")); assert!(!contents.contains("cdn.example")); } assert_eq!(fs::read(source_file).unwrap(), source_bytes); assert!(report.exists()); } #[test] fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() { for event in ["progress", "ended", "closed"] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = miruro_video(1); let (_, report) = state .miruro_session(&video, &miruro_source(&video)) .unwrap(); append( &report, miruro_record(&miruro_video(2), event, true, 100.0, Some(100.0)).as_bytes(), ); assert!(!state.poll_progress().unwrap()); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&miruro_video(2).id)); for (eligible, position, duration) in [ (true, 0.0, Some(100.0)), (true, 94.9, Some(100.0)), (false, 100.0, Some(100.0)), (true, 100.0, None), ] { append( &report, miruro_record(&video, event, eligible, position, duration).as_bytes(), ); state.poll_progress().unwrap(); assert!(!state.is_watched(&video.id)); } assert!(state.contains(CollectionKind::Watching, &video.id)); let completion = miruro_record(&video, event, true, 95.0, Some(100.0)); append(&report, completion.as_bytes()); append(&report, completion.as_bytes()); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.poll_progress().unwrap()); } } #[test] fn miruro_v2_rejects_invalid_wire_schemas_numbers_and_namespaces() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = miruro_video(1); let (_, report) = state .miruro_session(&video, &miruro_source(&video)) .unwrap(); let valid = miruro_record(&video, "progress", true, 100.0, Some(100.0)); for line in [ valid.replace("\"version\":2", "\"version\":1"), valid.replace("\"version\":2", "\"version\":3"), valid.replace("\"provider\":\"miruro\"", "\"provider\":\"youtube\""), valid.replace("\"episode\":1", "\"episode\":0"), valid.replace("\"episode\":1", "\"episode\":2147483648"), valid.replace("\"position_seconds\":100.0", "\"position_seconds\":-1"), valid.replace("\"position_seconds\":100.0", "\"position_seconds\":1e999"), valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":0"), valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":-1"), valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":1e999"), valid.replace("\"event\":\"progress\"", "\"event\":\"other\""), valid.replacen('{', "{\"media_url\":null,", 1), record(&self::video().id, "ended", true, 100.0).replace("\"version\":1", "\"version\":2"), ] { append(&report, line.as_bytes()); assert!(state.poll_progress().is_err(), "{line}"); assert!(state.videos(CollectionKind::Watching).is_empty()); assert!(!state.is_watched(&video.id)); assert!(!state.poll_progress().unwrap()); } } #[test] fn miruro_partial_reports_and_bad_siblings_recover_after_restart() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = miruro_video(1); let (source, report) = state .miruro_session(&video, &miruro_source(&video)) .unwrap(); let completion = miruro_record(&video, "closed", true, 95.0, Some(100.0)); append(&report, &completion.as_bytes()[..completion.len() - 1]); assert!(!state.poll_progress().unwrap()); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(!state.poll_progress().unwrap()); let other = miruro_video(2); let (_, bad) = state .miruro_session(&other, &miruro_source(&other)) .unwrap(); append(&bad, b"not json\n"); append(&report, b"\n"); assert!(state.poll_progress().is_err()); assert!(state.is_watched(&video.id)); assert!(!state.is_watched(&other.id)); assert!(state.poll_progress().unwrap()); assert!(!state.poll_progress().unwrap()); drop(state); assert!(source.exists()); assert!(report.exists()); let state = State::open(&directory.config()).unwrap(); assert!(state.is_watched(&video.id)); } #[test] fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); for field in [ "series", "noncanonical", "empty-title", "control-title", "channel", "channel-id", "uploader", ] { let mut video = miruro_video(1); match field { "series" => video.id = "miruro-series:opaque_Show-1".into(), "noncanonical" => video.id = "miruro:opaque_Show-1:01".into(), "empty-title" => video.title = " ".into(), "control-title" => video.title.push('\n'), "channel" => video.channel = Some("YouTube".into()), "channel-id" => video.channel_id = self::video().channel_id, "uploader" => video.uploader_id = Some("@jawed".into()), _ => unreachable!(), } for kind in [ CollectionKind::WatchLater, CollectionKind::Saved, CollectionKind::Watching, ] { assert!(state.save(kind, &video).is_err(), "{field}"); } assert!(state.progress_file(&video).is_err(), "{field}"); } for id in [ "miruro-series:opaque_Show-1", "miruro:opaque_Show-1:01", "other:opaque_Show-1:1", ] { let mut watched = DocumentMut::new(); watched["version"] = value(1); let mut ids = Array::new(); ids.push(id); watched["watched"] = value(ids); assert!(load_watched(&watched).is_err()); } } #[test] fn miruro_source_sessions_validate_binding_size_and_maximum_identity_marker() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let mut video = miruro_video(i32::MAX as u32); video.id = Identity::new("a".repeat(512), i32::MAX as u32) .unwrap() .key(); let mut source = miruro_source(&video); source.identity.episode -= 1; assert!(state.miruro_session(&video, &source).is_err()); assert!(state.streams.is_empty()); source.identity = Identity::from_key(&video.id).unwrap(); source.title = Some("x".repeat(MAX_SOURCE_BYTES)); assert!(state.miruro_session(&video, &source).is_err()); assert!(state.streams.is_empty()); source.title = None; let (_, report) = state.miruro_session(&video, &source).unwrap(); append( &report, miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), ); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); drop(state); let state = State::open(&directory.config()).unwrap(); assert!(state.is_watched(&video.id)); } #[test] fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() { for missing_snapshot in [true, false] { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = miruro_video(1); let (_, report) = state .miruro_session(&video, &miruro_source(&video)) .unwrap(); if missing_snapshot { fs::remove_file(report.with_file_name("video.toml")).unwrap(); } else { append( &report.with_file_name("video-id"), &vec![b'x'; MAX_IDENTITY_BYTES as usize], ); } append( &report, miruro_record(&video, "ended", true, 100.0, Some(100.0)).as_bytes(), ); drop(state); let mut state = State::open(&directory.config()).unwrap(); assert!(state.poll_progress().is_err()); assert!(!state.is_watched(&video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); } } #[test] fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() { let directory = TestDirectory::new(); let mut state = State::open(&directory.config()).unwrap(); let video = video(); let report = state.progress_file(&video).unwrap(); register_identity(&report, &video.id).unwrap(); let valid = record(&video.id, "ended", true, 95.0); for url in [ format!("https://youtu.be/{}", video.id), format!("https://www.youtube.com/watch?v={}&t=1", video.id), format!("http://www.youtube.com/watch?v={}", video.id), ] { append( &report, valid .replace( &format!("https://www.youtube.com/watch?v={}", video.id), &url, ) .as_bytes(), ); assert!(state.poll_progress().is_err()); assert!(!state.is_watched(&video.id)); assert!(state.videos(CollectionKind::Watching).is_empty()); } append( &report, valid .replacen('{', "{\"legacy_extension\":true,", 1) .as_bytes(), ); assert!(state.poll_progress().unwrap()); assert!(state.is_watched(&video.id)); }