1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Serialize, Clone, Deserialize)]
9pub struct UpStatData {
10 #[serde(rename = "inc_coin")]
12 pub inc_coin: i64,
13
14 #[serde(rename = "inc_elec")]
16 pub inc_elec: i64,
17
18 #[serde(rename = "inc_fav")]
20 pub inc_fav: i64,
21
22 #[serde(rename = "inc_like")]
24 pub inc_like: i64,
25
26 #[serde(rename = "inc_share")]
28 pub inc_share: i64,
29
30 #[serde(rename = "incr_click")]
32 pub incr_click: i64,
33
34 #[serde(rename = "incr_dm")]
36 pub incr_dm: i64,
37
38 #[serde(rename = "incr_fans")]
40 pub incr_fans: i64,
41
42 #[serde(rename = "incr_reply")]
44 pub incr_reply: i64,
45
46 #[serde(rename = "total_click")]
48 pub total_click: i64,
49
50 #[serde(rename = "total_coin")]
52 pub total_coin: i64,
53
54 #[serde(rename = "total_dm")]
56 pub total_dm: i64,
57
58 #[serde(rename = "total_elec")]
60 pub total_elec: i64,
61
62 #[serde(rename = "total_fans")]
64 pub total_fans: i64,
65
66 #[serde(rename = "total_fav")]
68 pub total_fav: i64,
69
70 #[serde(rename = "total_like")]
72 pub total_like: i64,
73
74 #[serde(rename = "total_reply")]
76 pub total_reply: i64,
77
78 #[serde(rename = "total_share")]
80 pub total_share: i64,
81}
82
83#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct ArchiveCompareItem {
87 pub aid: i64,
89 pub bvid: String,
91 pub cover: String,
93 pub title: String,
95 pub pubtime: i64,
97 pub duration: i64,
99 pub stat: Stat,
100 #[serde(rename = "is_only_self")]
101 pub is_only_self: bool,
102 #[serde(rename = "hour_stat")]
103 pub hour_stat: Option<HourStat>,
104}
105
106#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct Stat {
109 #[serde(rename = "not_ready_field")]
110 pub not_ready_field: serde_json::Value,
111 pub play: i64,
113 pub vt: i64,
114 #[serde(rename = "full_play_ratio")]
117 pub full_play_ratio: i64,
118 #[serde(rename = "play_viewer_rate")]
120 pub play_viewer_rate: i64,
121 #[serde(rename = "play_viewer_rate_med")]
122 pub play_viewer_rate_med: i64,
123 #[serde(rename = "play_fan_rate")]
125 pub play_fan_rate: i64,
126 #[serde(rename = "play_fan_rate_med")]
127 pub play_fan_rate_med: i64,
128 #[serde(rename = "active_fans_rate")]
129 pub active_fans_rate: i64,
130 #[serde(rename = "active_fans_med")]
131 pub active_fans_med: i64,
132 #[serde(rename = "tm_rate")]
134 pub tm_rate: i64,
135 #[serde(rename = "tm_rate_med")]
137 pub tm_rate_med: i64,
138 #[serde(rename = "tm_fan_simi_rate_med")]
140 pub tm_fan_simi_rate_med: i64,
141 #[serde(rename = "tm_viewer_simi_rate_med")]
143 pub tm_viewer_simi_rate_med: i64,
144 #[serde(rename = "tm_fan_rate")]
146 pub tm_fan_rate: i64,
147 #[serde(rename = "tm_viewer_rate")]
149 pub tm_viewer_rate: i64,
150 #[serde(rename = "tm_pass_rate")]
152 pub tm_pass_rate: i64,
153 #[serde(rename = "tm_fan_pass_rate")]
155 pub tm_fan_pass_rate: i64,
156 #[serde(rename = "tm_viewer_pass_rate")]
158 pub tm_viewer_pass_rate: i64,
159 #[serde(rename = "crash_rate")]
161 pub crash_rate: i64,
162 #[serde(rename = "crash_rate_med")]
163 pub crash_rate_med: i64,
164 #[serde(rename = "crash_fan_simi_rate_med")]
166 pub crash_fan_simi_rate_med: i64,
167 #[serde(rename = "crash_viewer_simi_rate_med")]
169 pub crash_viewer_simi_rate_med: i64,
170 #[serde(rename = "crash_fan_rate")]
172 pub crash_fan_rate: i64,
173 #[serde(rename = "crash_viewer_rate")]
175 pub crash_viewer_rate: i64,
176 #[serde(rename = "interact_rate")]
178 pub interact_rate: i64,
179 #[serde(rename = "interact_rate_med")]
180 pub interact_rate_med: i64,
181 #[serde(rename = "interact_fan_simi_rate_med")]
183 pub interact_fan_simi_rate_med: i64,
184 #[serde(rename = "interact_viewer_simi_rate_med")]
186 pub interact_viewer_simi_rate_med: i64,
187 #[serde(rename = "interact_fan_rate")]
189 pub interact_fan_rate: i64,
190 #[serde(rename = "interact_viewer_rate")]
192 pub interact_viewer_rate: i64,
193 #[serde(rename = "avg_play_time")]
195 pub avg_play_time: i64,
196 #[serde(rename = "avg_play_time_int")]
197 pub avg_play_time_int: i64,
198 #[serde(rename = "total_new_attention_cnt")]
200 pub total_new_attention_cnt: i64,
201 #[serde(rename = "play_trans_fan_rate")]
203 pub play_trans_fan_rate: i64,
204 #[serde(rename = "play_trans_fan_rate_med")]
206 pub play_trans_fan_rate_med: i64,
207 pub like: i64,
209 pub comment: i64,
211 pub dm: i64,
213 pub fav: i64,
215 pub coin: i64,
217 pub share: i64,
219 #[serde(rename = "unfollow")]
220 pub unfollow: i64,
221 #[serde(rename = "tm_star")]
222 pub tm_star: i64,
223 #[serde(rename = "tm_viewer_star")]
224 pub tm_viewer_star: i64,
225 #[serde(rename = "tm_fan_star")]
226 pub tm_fan_star: i64,
227 #[serde(rename = "crash_p50")]
228 pub crash_p50: i64,
229 #[serde(rename = "crash_viewer_p50")]
230 pub crash_viewer_p50: i64,
231 #[serde(rename = "crash_fan_p50")]
232 pub crash_fan_p50: i64,
233 #[serde(rename = "interact_p50")]
234 pub interact_p50: i64,
235 #[serde(rename = "interact_viewer_p50")]
236 pub interact_viewer_p50: i64,
237 #[serde(rename = "interact_fan_p50")]
238 pub interact_fan_p50: i64,
239 #[serde(rename = "play_trans_fan_p50")]
240 pub play_trans_fan_p50: i64,
241}
242
243#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct HourStat {
246 #[serde(rename = "not_ready_field")]
247 pub not_ready_field: serde_json::Value,
248 pub play: i64,
250 pub vt: i64,
251 pub like: i64,
253 pub comment: i64,
255 pub dm: i64,
257 pub fav: i64,
259 pub coin: i64,
261 pub share: i64,
263 #[serde(rename = "tm_pass_rate")]
265 pub tm_pass_rate: i64,
266 #[serde(rename = "interact_rate")]
268 pub interact_rate: i64,
269 #[serde(rename = "tm_star")]
270 pub tm_star: i64,
271}
272
273#[derive(Debug, Serialize, Clone, Deserialize)]
275pub struct ArchiveCompareData {
276 pub list: Vec<ArchiveCompareItem>,
277}
278
279#[derive(Debug, Serialize, Clone, Deserialize)]
281pub struct UpArticleStatData {
282 pub view: i64,
284 pub reply: i64,
286 pub like: i64,
288 pub coin: i64,
290 pub fav: i64,
292 pub share: i64,
294 #[serde(rename = "incr_view")]
296 pub incr_view: i64,
297 #[serde(rename = "incr_reply")]
299 pub incr_reply: i64,
300 #[serde(rename = "incr_like")]
302 pub incr_like: i64,
303 #[serde(rename = "incr_coin")]
305 pub incr_coin: i64,
306 #[serde(rename = "incr_fav")]
308 pub incr_fav: i64,
309 #[serde(rename = "incr_share")]
311 pub incr_share: i64,
312}
313
314#[derive(Debug, Serialize, Clone, Deserialize)]
316pub struct VideoTrendItem {
317 pub date_key: i64,
319 pub total_inc: i64,
321}
322
323#[derive(Debug, Serialize, Clone, Deserialize)]
325pub struct ArticleTrendItem {
326 pub date_key: i64,
328 pub total_inc: i64,
330}
331
332#[derive(Debug, Serialize, Clone, Deserialize)]
334pub struct PageSource {
335 pub dynamic: i64,
337 pub other: i64,
339 #[serde(rename = "related_video")]
341 pub related_video: i64,
342 pub search: i64,
344 pub space: i64,
346 pub tenma: i64,
348}
349
350#[derive(Debug, Serialize, Clone, Deserialize)]
352pub struct PlayProportion {
353 pub android: i64,
355 pub h5: i64,
357 pub ios: i64,
359 pub out: i64,
361 pub pc: i64,
363}
364
365#[derive(Debug, Serialize, Clone, Deserialize)]
367pub struct PlaySourceData {
368 pub page_source: PageSource,
369 pub play_proportion: PlayProportion,
370}
371
372#[derive(Debug, Serialize, Clone, Deserialize)]
374pub struct Period {
375 pub module_one: Option<String>,
376 pub module_two: Option<String>,
377 pub module_three: Option<String>,
378 pub module_four: Option<String>,
379}
380
381pub type ViewerAreaMap = std::collections::HashMap<String, i64>;
383
384#[derive(Debug, Serialize, Clone, Deserialize)]
385pub struct ViewerArea {
386 pub fan: ViewerAreaMap,
387 pub not_fan: ViewerAreaMap,
388}
389
390#[derive(Debug, Serialize, Clone, Deserialize)]
392pub struct ViewerBaseDetail {
393 pub male: i64,
394 pub female: i64,
395 #[serde(rename = "age_one")]
396 pub age_one: i64,
397 #[serde(rename = "age_two")]
398 pub age_two: i64,
399 #[serde(rename = "age_three")]
400 pub age_three: i64,
401 #[serde(rename = "age_four")]
402 pub age_four: i64,
403 #[serde(rename = "plat_pc")]
404 pub plat_pc: i64,
405 #[serde(rename = "plat_h5")]
406 pub plat_h5: i64,
407 #[serde(rename = "plat_out")]
408 pub plat_out: i64,
409 #[serde(rename = "plat_ios")]
410 pub plat_ios: i64,
411 #[serde(rename = "plat_android")]
412 pub plat_android: i64,
413 #[serde(rename = "plat_other_app")]
414 pub plat_other_app: i64,
415}
416
417#[derive(Debug, Serialize, Clone, Deserialize)]
418pub struct ViewerBase {
419 pub fan: ViewerBaseDetail,
420 pub not_fan: ViewerBaseDetail,
421}
422
423#[derive(Debug, Serialize, Clone, Deserialize)]
425pub struct ViewerData {
426 pub period: Period,
427 pub viewer_area: ViewerArea,
428 pub viewer_base: ViewerBase,
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::creativecenter::{
435 UpArchiveCompareParams, UpArticleTrendMetric, UpArticleTrendParams, UpVideoTrendMetric,
436 UpVideoTrendParams,
437 };
438 use crate::probe::contract::HttpMethod;
439 use crate::probe::endpoint_contract::EndpointContract;
440 use crate::{ApiEnvelope, BpiClient, BpiError};
441 use std::collections::BTreeMap;
442 use tracing::info;
443
444 fn contract(name: &str) -> Result<EndpointContract, BpiError> {
445 let bytes = match name {
446 "up-stat" => include_bytes!(
447 "../../tests/contracts/creativecenter/statistics/up-stat/contract.json"
448 )
449 .as_slice(),
450 "archive-compare" => include_bytes!(
451 "../../tests/contracts/creativecenter/statistics/archive-compare/contract.json"
452 )
453 .as_slice(),
454 "article-stat" => include_bytes!(
455 "../../tests/contracts/creativecenter/statistics/article-stat/contract.json"
456 )
457 .as_slice(),
458 "video-trend" => include_bytes!(
459 "../../tests/contracts/creativecenter/statistics/video-trend/contract.json"
460 )
461 .as_slice(),
462 "article-trend" => include_bytes!(
463 "../../tests/contracts/creativecenter/statistics/article-trend/contract.json"
464 )
465 .as_slice(),
466 "play-source" => include_bytes!(
467 "../../tests/contracts/creativecenter/statistics/play-source/contract.json"
468 )
469 .as_slice(),
470 "viewer-data" => include_bytes!(
471 "../../tests/contracts/creativecenter/statistics/viewer-data/contract.json"
472 )
473 .as_slice(),
474 _ => unreachable!("unknown creativecenter statistics contract"),
475 };
476 EndpointContract::from_slice(bytes)
477 }
478
479 fn query_map<I>(params: I) -> BTreeMap<String, String>
480 where
481 I: IntoIterator<Item = (&'static str, String)>,
482 {
483 params
484 .into_iter()
485 .map(|(key, value)| (key.to_string(), value))
486 .collect()
487 }
488
489 fn live_creativecenter_tests_enabled() -> bool {
490 std::env::var_os("BPI_LIVE_TEST").is_some() && std::env::var_os("BPI_COOKIE").is_some()
491 }
492
493 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
494 #[tokio::test]
495 async fn test_up_stat() -> Result<(), Box<BpiError>> {
496 if !live_creativecenter_tests_enabled() {
497 return Ok(());
498 }
499
500 let bpi = BpiClient::new().expect("client should build");
501 let data = bpi.creativecenter().up_stat().await?;
502 info!("UP主视频状态数据: {:?}", data);
503 Ok(())
504 }
505
506 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
507 #[tokio::test]
508 async fn test_archive_compare() -> Result<(), Box<BpiError>> {
509 if !live_creativecenter_tests_enabled() {
510 return Ok(());
511 }
512
513 let bpi = BpiClient::new().expect("client should build");
514 let params = UpArchiveCompareParams::new().with_size(3)?;
515 let data = bpi.creativecenter().archive_compare(params).await?;
516 info!("UP主视频数据比较: {:?}", data);
517 Ok(())
518 }
519
520 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
521 #[tokio::test]
522 async fn test_up_article_stat() -> Result<(), Box<BpiError>> {
523 if !live_creativecenter_tests_enabled() {
524 return Ok(());
525 }
526
527 let bpi = BpiClient::new().expect("client should build");
528 let data = bpi.creativecenter().article_stat().await?;
529 info!("UP主专栏状态数据: {:?}", data);
530 Ok(())
531 }
532
533 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
534 #[tokio::test]
535 async fn test_video_trend() -> Result<(), Box<BpiError>> {
536 if !live_creativecenter_tests_enabled() {
537 return Ok(());
538 }
539
540 let bpi = BpiClient::new().expect("client should build");
541 let params = UpVideoTrendParams::new(UpVideoTrendMetric::Play);
542 let data = bpi.creativecenter().video_trend(params).await?;
543 info!("UP主视频数据增量趋势: {:?}", data);
544 Ok(())
545 }
546
547 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
548 #[tokio::test]
549 async fn test_article_trend() -> Result<(), Box<BpiError>> {
550 if !live_creativecenter_tests_enabled() {
551 return Ok(());
552 }
553
554 let bpi = BpiClient::new().expect("client should build");
555 let params = UpArticleTrendParams::new(UpArticleTrendMetric::Read);
556 let data = bpi.creativecenter().article_trend(params).await?;
557 info!("UP主专栏数据增量趋势: {:?}", data);
558 Ok(())
559 }
560
561 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
562 #[tokio::test]
563 async fn test_viewer_data() -> Result<(), Box<BpiError>> {
564 if !live_creativecenter_tests_enabled() {
565 return Ok(());
566 }
567
568 let bpi = BpiClient::new().expect("client should build");
569 let data = bpi.creativecenter().viewer_data().await?;
570 info!("播放分布情况: {:?}", data);
571 Ok(())
572 }
573
574 #[test]
575 fn creativecenter_statistics_contracts_match_endpoint_requests() -> Result<(), BpiError> {
576 let up_stat = contract("up-stat")?;
577 assert_eq!(up_stat.name, "creativecenter.statistics.up_stat");
578 assert_eq!(up_stat.request.method, HttpMethod::Get);
579 assert_eq!(
580 up_stat.request.url.as_str(),
581 "https://member.bilibili.com/x/web/index/stat"
582 );
583 assert!(up_stat.request.query.is_empty());
584
585 let archive_compare = contract("archive-compare")?;
586 let archive_compare_params = UpArchiveCompareParams::new().with_size(3)?;
587 assert_eq!(
588 archive_compare.name,
589 "creativecenter.statistics.archive_compare"
590 );
591 assert_eq!(
592 archive_compare.request.url.as_str(),
593 "https://member.bilibili.com/x/web/data/archive_diagnose/compare"
594 );
595 assert_eq!(
596 query_map(archive_compare_params.query_pairs()),
597 archive_compare.request.query
598 );
599
600 let article_stat = contract("article-stat")?;
601 assert_eq!(
602 article_stat.request.url.as_str(),
603 "https://member.bilibili.com/x/web/data/article"
604 );
605 assert!(article_stat.request.query.is_empty());
606
607 let video_trend = contract("video-trend")?;
608 let video_trend_params = UpVideoTrendParams::new(UpVideoTrendMetric::Play);
609 assert_eq!(
610 video_trend.request.url.as_str(),
611 "https://member.bilibili.com/x/web/data/pandect"
612 );
613 assert_eq!(
614 query_map(video_trend_params.query_pairs()),
615 video_trend.request.query
616 );
617
618 let article_trend = contract("article-trend")?;
619 let article_trend_params = UpArticleTrendParams::new(UpArticleTrendMetric::Read);
620 assert_eq!(
621 article_trend.request.url.as_str(),
622 "https://member.bilibili.com/x/web/data/article/thirty"
623 );
624 assert_eq!(
625 query_map(article_trend_params.query_pairs()),
626 article_trend.request.query
627 );
628
629 let play_source = contract("play-source")?;
630 assert_eq!(
631 play_source.request.url.as_str(),
632 "https://member.bilibili.com/x/web/data/playsource"
633 );
634 assert_eq!(
635 play_source
636 .request
637 .headers
638 .get("Origin")
639 .map(String::as_str),
640 Some("https://www.bilibili.com")
641 );
642
643 let viewer_data = contract("viewer-data")?;
644 assert_eq!(
645 viewer_data.request.url.as_str(),
646 "https://member.bilibili.com/x/web/data/base"
647 );
648 assert!(viewer_data.request.query.is_empty());
649
650 Ok(())
651 }
652
653 #[test]
654 fn creativecenter_statistics_response_fixtures_parse_declared_models() -> Result<(), BpiError> {
655 for bytes in [
656 include_bytes!(
657 "../../tests/contracts/creativecenter/statistics/up-stat/responses/normal.success.json"
658 )
659 .as_slice(),
660 include_bytes!(
661 "../../tests/contracts/creativecenter/statistics/up-stat/responses/vip.success.json"
662 )
663 .as_slice(),
664 ] {
665 let payload = ApiEnvelope::<UpStatData>::from_slice(bytes)?.into_payload()?;
666 assert_eq!(payload.total_click, 0);
667 }
668
669 for bytes in [
670 include_bytes!(
671 "../../tests/contracts/creativecenter/statistics/archive-compare/responses/normal.success.json"
672 )
673 .as_slice(),
674 include_bytes!(
675 "../../tests/contracts/creativecenter/statistics/archive-compare/responses/vip.success.json"
676 )
677 .as_slice(),
678 ] {
679 let payload = ApiEnvelope::<ArchiveCompareData>::from_slice(bytes)?.into_payload()?;
680 assert_eq!(payload.list.len(), 1);
681 }
682
683 for bytes in [
684 include_bytes!(
685 "../../tests/contracts/creativecenter/statistics/article-stat/responses/normal.success.json"
686 )
687 .as_slice(),
688 include_bytes!(
689 "../../tests/contracts/creativecenter/statistics/article-stat/responses/vip.success.json"
690 )
691 .as_slice(),
692 ] {
693 let payload = ApiEnvelope::<UpArticleStatData>::from_slice(bytes)?.into_payload()?;
694 assert_eq!(payload.view, 0);
695 }
696
697 for bytes in [
698 include_bytes!(
699 "../../tests/contracts/creativecenter/statistics/video-trend/responses/normal.success.json"
700 )
701 .as_slice(),
702 include_bytes!(
703 "../../tests/contracts/creativecenter/statistics/video-trend/responses/vip.success.json"
704 )
705 .as_slice(),
706 ] {
707 let payload =
708 ApiEnvelope::<Vec<VideoTrendItem>>::from_slice(bytes)?.into_payload()?;
709 assert_eq!(payload.len(), 1);
710 }
711
712 let normal_article_trend = ApiEnvelope::<Vec<ArticleTrendItem>>::from_slice(include_bytes!(
713 "../../tests/contracts/creativecenter/statistics/article-trend/responses/normal.success.json"
714 ))?
715 .into_optional_payload()?;
716 assert!(normal_article_trend.is_none());
717
718 let vip_article_trend = ApiEnvelope::<Vec<ArticleTrendItem>>::from_slice(include_bytes!(
719 "../../tests/contracts/creativecenter/statistics/article-trend/responses/vip.success.json"
720 ))?
721 .into_payload()?;
722 assert_eq!(vip_article_trend.len(), 1);
723
724 for bytes in [
725 include_bytes!(
726 "../../tests/contracts/creativecenter/statistics/play-source/responses/normal.success.json"
727 )
728 .as_slice(),
729 include_bytes!(
730 "../../tests/contracts/creativecenter/statistics/play-source/responses/vip.success.json"
731 )
732 .as_slice(),
733 ] {
734 let payload = ApiEnvelope::<PlaySourceData>::from_slice(bytes)?.into_optional_payload()?;
735 assert!(payload.is_none());
736 }
737
738 for bytes in [
739 include_bytes!(
740 "../../tests/contracts/creativecenter/statistics/viewer-data/responses/normal.success.json"
741 )
742 .as_slice(),
743 include_bytes!(
744 "../../tests/contracts/creativecenter/statistics/viewer-data/responses/vip.success.json"
745 )
746 .as_slice(),
747 ] {
748 let payload = ApiEnvelope::<ViewerData>::from_slice(bytes)?.into_payload()?;
749 assert_eq!(payload.viewer_area.fan.get("<redacted>"), Some(&0));
750 }
751
752 Ok(())
753 }
754
755 #[test]
756 fn creativecenter_statistics_error_fixtures_preserve_observed_api_errors()
757 -> Result<(), BpiError> {
758 for bytes in [
759 include_bytes!(
760 "../../tests/contracts/creativecenter/statistics/up-stat/responses/anonymous.requires_login.json"
761 )
762 .as_slice(),
763 include_bytes!(
764 "../../tests/contracts/creativecenter/statistics/archive-compare/responses/anonymous.requires_login.json"
765 )
766 .as_slice(),
767 include_bytes!(
768 "../../tests/contracts/creativecenter/statistics/article-stat/responses/anonymous.requires_login.json"
769 )
770 .as_slice(),
771 include_bytes!(
772 "../../tests/contracts/creativecenter/statistics/video-trend/responses/anonymous.requires_login.json"
773 )
774 .as_slice(),
775 include_bytes!(
776 "../../tests/contracts/creativecenter/statistics/article-trend/responses/anonymous.requires_login.json"
777 )
778 .as_slice(),
779 include_bytes!(
780 "../../tests/contracts/creativecenter/statistics/play-source/responses/anonymous.requires_login.json"
781 )
782 .as_slice(),
783 include_bytes!(
784 "../../tests/contracts/creativecenter/statistics/viewer-data/responses/anonymous.requires_login.json"
785 )
786 .as_slice(),
787 ] {
788 let err = ApiEnvelope::<serde_json::Value>::from_slice(bytes)
789 .and_then(ApiEnvelope::ensure_success)
790 .unwrap_err();
791 assert!(err.requires_login());
792 }
793 Ok(())
794 }
795
796 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
797 let path = format!(
798 "target/bpi-probe-runs/creativecenter/statistics-read/{endpoint}/{profile}.response.json"
799 );
800 let bytes = std::fs::read(path).ok()?;
801 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
802 value
803 .get("response")
804 .and_then(|response| response.get("body"))
805 .cloned()
806 }
807
808 #[test]
809 fn creativecenter_statistics_models_match_local_probe_outputs_when_available()
810 -> Result<(), BpiError> {
811 for profile in ["normal", "vip"] {
812 if let Some(body) = local_probe_body("up-stat", profile) {
813 let _payload =
814 serde_json::from_value::<ApiEnvelope<UpStatData>>(body)?.into_payload()?;
815 }
816
817 if let Some(body) = local_probe_body("archive-compare", profile) {
818 let payload = serde_json::from_value::<ApiEnvelope<ArchiveCompareData>>(body)?
819 .into_payload()?;
820 assert!(!payload.list.is_empty());
821 }
822
823 if let Some(body) = local_probe_body("article-stat", profile) {
824 let _payload = serde_json::from_value::<ApiEnvelope<UpArticleStatData>>(body)?
825 .into_payload()?;
826 }
827
828 if let Some(body) = local_probe_body("video-trend", profile) {
829 let payload = serde_json::from_value::<ApiEnvelope<Vec<VideoTrendItem>>>(body)?
830 .into_payload()?;
831 assert!(!payload.is_empty());
832 }
833
834 if let Some(body) = local_probe_body("article-trend", profile) {
835 let payload = serde_json::from_value::<ApiEnvelope<Vec<ArticleTrendItem>>>(body)?
836 .into_optional_payload()?;
837 if profile == "vip" {
838 assert!(payload.as_ref().is_some_and(|items| !items.is_empty()));
839 } else {
840 assert!(payload.is_none());
841 }
842 }
843
844 if let Some(body) = local_probe_body("play-source", profile) {
845 let payload = serde_json::from_value::<ApiEnvelope<PlaySourceData>>(body)?
846 .into_optional_payload()?;
847 assert!(payload.is_none());
848 }
849
850 if let Some(body) = local_probe_body("viewer-data", profile) {
851 let payload =
852 serde_json::from_value::<ApiEnvelope<ViewerData>>(body)?.into_payload()?;
853 assert!(!payload.viewer_area.fan.is_empty());
854 }
855 }
856 Ok(())
857 }
858}