Skip to main content

tuitbot_core/context/
author.rs

1//! Author context aggregation.
2//!
3//! Builds a rich profile of an author from stored interaction history,
4//! conversation records, performance metrics, and risk signals.
5
6use crate::config::Config;
7use crate::error::StorageError;
8use crate::storage::DbPool;
9use serde::Serialize;
10
11/// Complete context profile for an author.
12#[derive(Debug, Clone, Serialize)]
13pub struct AuthorContext {
14    pub author_username: String,
15    pub author_id: Option<String>,
16    pub interaction_summary: InteractionSummary,
17    pub conversation_history: Vec<ConversationRecord>,
18    pub topic_affinity: Vec<TopicAffinity>,
19    pub risk_signals: Vec<RiskSignal>,
20    pub response_metrics: ResponseMetrics,
21}
22
23/// Summary of interaction history with an author.
24#[derive(Debug, Clone, Serialize)]
25pub struct InteractionSummary {
26    pub total_replies_sent: i64,
27    pub replies_today: i64,
28    pub first_interaction: Option<String>,
29    pub last_interaction: Option<String>,
30    pub distinct_days_active: i64,
31}
32
33/// A single conversation record (our reply to their tweet).
34#[derive(Debug, Clone, Serialize)]
35pub struct ConversationRecord {
36    pub tweet_id: String,
37    pub tweet_content: String,
38    pub reply_content: String,
39    pub reply_status: String,
40    pub created_at: String,
41    pub performance: Option<PerformanceSnapshot>,
42}
43
44/// Performance metrics for a single reply.
45#[derive(Debug, Clone, Serialize)]
46pub struct PerformanceSnapshot {
47    pub likes: i64,
48    pub replies_received: i64,
49    pub impressions: i64,
50    pub performance_score: f64,
51}
52
53/// A keyword/topic that appears in an author's tweets.
54#[derive(Debug, Clone, Serialize)]
55pub struct TopicAffinity {
56    pub keyword: String,
57    pub mention_count: i64,
58}
59
60/// A risk signal that may affect engagement decisions.
61#[derive(Debug, Clone, Serialize)]
62pub struct RiskSignal {
63    pub signal_type: String,
64    pub severity: String,
65    pub description: String,
66}
67
68/// Aggregate response metrics for interactions with this author.
69#[derive(Debug, Clone, Serialize)]
70pub struct ResponseMetrics {
71    pub replies_with_engagement: i64,
72    pub replies_measured: i64,
73    pub response_rate: f64,
74    pub avg_performance_score: f64,
75}
76
77type ConvRow = (String, String, String, String, String, Option<String>);
78type PerfRow = (i64, i64, i64, f64);
79
80/// Build a complete author context from stored data.
81///
82/// Accepts a username (with or without @) or an author ID.
83pub async fn get_author_context(
84    pool: &DbPool,
85    identifier: &str,
86    config: &Config,
87) -> Result<AuthorContext, StorageError> {
88    let username = identifier.trim_start_matches('@');
89
90    // Resolve author identity from discovered_tweets
91    let (author_id, author_username) = resolve_author(pool, username, identifier).await?;
92
93    // Gather interaction summary
94    let interaction_summary = query_interaction_summary(pool, &author_id, &author_username).await?;
95
96    // Gather conversation history with performance data
97    let conversation_history = query_conversation_history(pool, &author_username).await?;
98
99    // Compute response metrics from conversation history
100    let response_metrics = compute_response_metrics(&conversation_history);
101
102    // Extract topic affinity from discovered tweets
103    let topic_affinity = query_topic_affinity(pool, &author_username).await?;
104
105    // Generate risk signals
106    let risk_signals = generate_risk_signals(
107        &interaction_summary,
108        &response_metrics,
109        config.limits.max_replies_per_author_per_day,
110    );
111
112    Ok(AuthorContext {
113        author_username,
114        author_id,
115        interaction_summary,
116        conversation_history,
117        topic_affinity,
118        risk_signals,
119        response_metrics,
120    })
121}
122
123async fn resolve_author(
124    pool: &DbPool,
125    username: &str,
126    raw_identifier: &str,
127) -> Result<(Option<String>, String), StorageError> {
128    // Try by username first
129    let row: Option<(String,)> =
130        sqlx::query_as("SELECT author_id FROM discovered_tweets WHERE author_username = ? LIMIT 1")
131            .bind(username)
132            .fetch_optional(pool)
133            .await
134            .map_err(|e| StorageError::Query { source: e })?;
135
136    if let Some((id,)) = row {
137        return Ok((Some(id), username.to_string()));
138    }
139
140    // Fall back to lookup by author_id
141    let row: Option<(String,)> =
142        sqlx::query_as("SELECT author_username FROM discovered_tweets WHERE author_id = ? LIMIT 1")
143            .bind(raw_identifier)
144            .fetch_optional(pool)
145            .await
146            .map_err(|e| StorageError::Query { source: e })?;
147
148    match row {
149        Some((uname,)) => Ok((Some(raw_identifier.to_string()), uname)),
150        None => Ok((None, username.to_string())),
151    }
152}
153
154async fn query_interaction_summary(
155    pool: &DbPool,
156    author_id: &Option<String>,
157    author_username: &str,
158) -> Result<InteractionSummary, StorageError> {
159    let row: Option<(i64, Option<String>, Option<String>, i64)> = sqlx::query_as(
160        "SELECT COALESCE(SUM(reply_count), 0), \
161                MIN(interaction_date), \
162                MAX(interaction_date), \
163                COUNT(DISTINCT interaction_date) \
164         FROM author_interactions \
165         WHERE author_id = ? OR author_username = ?",
166    )
167    .bind(author_id.as_deref().unwrap_or(""))
168    .bind(author_username)
169    .fetch_optional(pool)
170    .await
171    .map_err(|e| StorageError::Query { source: e })?;
172
173    let (total, first, last, distinct) = row.unwrap_or((0, None, None, 0));
174
175    // Get today's count
176    let today_row: (i64,) = sqlx::query_as(
177        "SELECT COALESCE(SUM(reply_count), 0) \
178         FROM author_interactions \
179         WHERE (author_id = ? OR author_username = ?) \
180           AND interaction_date = date('now')",
181    )
182    .bind(author_id.as_deref().unwrap_or(""))
183    .bind(author_username)
184    .fetch_one(pool)
185    .await
186    .map_err(|e| StorageError::Query { source: e })?;
187
188    Ok(InteractionSummary {
189        total_replies_sent: total,
190        replies_today: today_row.0,
191        first_interaction: first,
192        last_interaction: last,
193        distinct_days_active: distinct,
194    })
195}
196
197async fn query_conversation_history(
198    pool: &DbPool,
199    author_username: &str,
200) -> Result<Vec<ConversationRecord>, StorageError> {
201    let rows: Vec<ConvRow> = sqlx::query_as(
202        "SELECT dt.id, SUBSTR(dt.content, 1, 200), \
203                rs.reply_content, rs.status, rs.created_at, rs.reply_tweet_id \
204         FROM replies_sent rs \
205         JOIN discovered_tweets dt ON dt.id = rs.target_tweet_id \
206         WHERE dt.author_username = ? \
207         ORDER BY rs.created_at DESC \
208         LIMIT 20",
209    )
210    .bind(author_username)
211    .fetch_all(pool)
212    .await
213    .map_err(|e| StorageError::Query { source: e })?;
214
215    let mut records = Vec::with_capacity(rows.len());
216    for (tweet_id, tweet_content, reply_content, status, created_at, reply_tweet_id) in rows {
217        let performance = if let Some(ref rtid) = reply_tweet_id {
218            query_reply_performance(pool, rtid).await?
219        } else {
220            None
221        };
222        records.push(ConversationRecord {
223            tweet_id,
224            tweet_content,
225            reply_content,
226            reply_status: status,
227            created_at,
228            performance,
229        });
230    }
231    Ok(records)
232}
233
234async fn query_reply_performance(
235    pool: &DbPool,
236    reply_tweet_id: &str,
237) -> Result<Option<PerformanceSnapshot>, StorageError> {
238    let row: Option<PerfRow> = sqlx::query_as(
239        "SELECT likes_received, replies_received, impressions, performance_score \
240         FROM reply_performance WHERE reply_id = ?",
241    )
242    .bind(reply_tweet_id)
243    .fetch_optional(pool)
244    .await
245    .map_err(|e| StorageError::Query { source: e })?;
246
247    Ok(
248        row.map(|(likes, replies, impressions, score)| PerformanceSnapshot {
249            likes,
250            replies_received: replies,
251            impressions,
252            performance_score: score,
253        }),
254    )
255}
256
257fn compute_response_metrics(history: &[ConversationRecord]) -> ResponseMetrics {
258    let measured = history.iter().filter(|c| c.performance.is_some()).count() as i64;
259    let with_engagement = history
260        .iter()
261        .filter(|c| {
262            c.performance
263                .as_ref()
264                .is_some_and(|p| p.likes > 0 || p.replies_received > 0)
265        })
266        .count() as i64;
267    let avg_score = if measured > 0 {
268        history
269            .iter()
270            .filter_map(|c| c.performance.as_ref())
271            .map(|p| p.performance_score)
272            .sum::<f64>()
273            / measured as f64
274    } else {
275        0.0
276    };
277    let rate = if measured > 0 {
278        with_engagement as f64 / measured as f64
279    } else {
280        0.0
281    };
282
283    ResponseMetrics {
284        replies_with_engagement: with_engagement,
285        replies_measured: measured,
286        response_rate: rate,
287        avg_performance_score: avg_score,
288    }
289}
290
291async fn query_topic_affinity(
292    pool: &DbPool,
293    author_username: &str,
294) -> Result<Vec<TopicAffinity>, StorageError> {
295    let rows: Vec<(String, i64)> = sqlx::query_as(
296        "SELECT matched_keyword, COUNT(*) as cnt \
297         FROM discovered_tweets \
298         WHERE author_username = ? \
299           AND matched_keyword IS NOT NULL AND matched_keyword != '' \
300         GROUP BY matched_keyword \
301         ORDER BY cnt DESC \
302         LIMIT 10",
303    )
304    .bind(author_username)
305    .fetch_all(pool)
306    .await
307    .map_err(|e| StorageError::Query { source: e })?;
308
309    Ok(rows
310        .into_iter()
311        .map(|(keyword, count)| TopicAffinity {
312            keyword,
313            mention_count: count,
314        })
315        .collect())
316}
317
318/// Generate risk signals based on interaction and response data.
319fn generate_risk_signals(
320    summary: &InteractionSummary,
321    metrics: &ResponseMetrics,
322    max_per_author_per_day: u32,
323) -> Vec<RiskSignal> {
324    let mut signals = Vec::new();
325
326    if summary.replies_today >= max_per_author_per_day as i64 {
327        signals.push(RiskSignal {
328            signal_type: "high_frequency_today".to_string(),
329            severity: "high".to_string(),
330            description: format!(
331                "Already sent {} replies today (limit: {})",
332                summary.replies_today, max_per_author_per_day
333            ),
334        });
335    }
336
337    if metrics.replies_measured >= 3 && metrics.response_rate < 0.1 {
338        signals.push(RiskSignal {
339            signal_type: "low_response_rate".to_string(),
340            severity: "medium".to_string(),
341            description: format!(
342                "Only {:.0}% of replies to this author received engagement ({}/{})",
343                metrics.response_rate * 100.0,
344                metrics.replies_with_engagement,
345                metrics.replies_measured
346            ),
347        });
348    }
349
350    if summary.total_replies_sent > 0 && metrics.replies_measured == 0 {
351        signals.push(RiskSignal {
352            signal_type: "no_measured_performance".to_string(),
353            severity: "low".to_string(),
354            description: "Replied before but no performance data collected yet".to_string(),
355        });
356    }
357
358    if summary.total_replies_sent == 0 {
359        signals.push(RiskSignal {
360            signal_type: "no_prior_interaction".to_string(),
361            severity: "low".to_string(),
362            description: "No prior interaction history with this author".to_string(),
363        });
364    }
365
366    signals
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::storage::init_test_db;
373
374    fn empty_summary() -> InteractionSummary {
375        InteractionSummary {
376            total_replies_sent: 0,
377            replies_today: 0,
378            first_interaction: None,
379            last_interaction: None,
380            distinct_days_active: 0,
381        }
382    }
383
384    fn active_summary() -> InteractionSummary {
385        InteractionSummary {
386            total_replies_sent: 10,
387            replies_today: 3,
388            first_interaction: Some("2026-01-01T00:00:00Z".to_string()),
389            last_interaction: Some("2026-03-15T00:00:00Z".to_string()),
390            distinct_days_active: 8,
391        }
392    }
393
394    fn zero_metrics() -> ResponseMetrics {
395        ResponseMetrics {
396            replies_with_engagement: 0,
397            replies_measured: 0,
398            response_rate: 0.0,
399            avg_performance_score: 0.0,
400        }
401    }
402
403    fn low_engagement_metrics() -> ResponseMetrics {
404        ResponseMetrics {
405            replies_with_engagement: 0,
406            replies_measured: 5,
407            response_rate: 0.0,
408            avg_performance_score: 10.0,
409        }
410    }
411
412    fn good_metrics() -> ResponseMetrics {
413        ResponseMetrics {
414            replies_with_engagement: 4,
415            replies_measured: 5,
416            response_rate: 0.8,
417            avg_performance_score: 75.0,
418        }
419    }
420
421    // ============================================================
422    // compute_response_metrics tests
423    // ============================================================
424
425    #[test]
426    fn compute_metrics_empty_history() {
427        let metrics = compute_response_metrics(&[]);
428        assert_eq!(metrics.replies_measured, 0);
429        assert_eq!(metrics.replies_with_engagement, 0);
430        assert_eq!(metrics.response_rate, 0.0);
431        assert_eq!(metrics.avg_performance_score, 0.0);
432    }
433
434    #[test]
435    fn compute_metrics_no_performance_data() {
436        let history = vec![ConversationRecord {
437            tweet_id: "t1".to_string(),
438            tweet_content: "Hello".to_string(),
439            reply_content: "Hi there!".to_string(),
440            reply_status: "sent".to_string(),
441            created_at: "2026-01-01T10:00:00Z".to_string(),
442            performance: None,
443        }];
444        let metrics = compute_response_metrics(&history);
445        assert_eq!(metrics.replies_measured, 0);
446        assert_eq!(metrics.avg_performance_score, 0.0);
447    }
448
449    #[test]
450    fn compute_metrics_with_performance() {
451        let history = vec![
452            ConversationRecord {
453                tweet_id: "t1".to_string(),
454                tweet_content: "Hello".to_string(),
455                reply_content: "Hi!".to_string(),
456                reply_status: "sent".to_string(),
457                created_at: "2026-01-01T10:00:00Z".to_string(),
458                performance: Some(PerformanceSnapshot {
459                    likes: 5,
460                    replies_received: 2,
461                    impressions: 500,
462                    performance_score: 80.0,
463                }),
464            },
465            ConversationRecord {
466                tweet_id: "t2".to_string(),
467                tweet_content: "World".to_string(),
468                reply_content: "Hey!".to_string(),
469                reply_status: "sent".to_string(),
470                created_at: "2026-01-02T10:00:00Z".to_string(),
471                performance: Some(PerformanceSnapshot {
472                    likes: 0,
473                    replies_received: 0,
474                    impressions: 100,
475                    performance_score: 20.0,
476                }),
477            },
478        ];
479        let metrics = compute_response_metrics(&history);
480        assert_eq!(metrics.replies_measured, 2);
481        assert_eq!(metrics.replies_with_engagement, 1);
482        assert!((metrics.response_rate - 0.5).abs() < 0.01);
483        assert!((metrics.avg_performance_score - 50.0).abs() < 0.01);
484    }
485
486    #[test]
487    fn compute_metrics_all_with_engagement() {
488        let history = vec![
489            ConversationRecord {
490                tweet_id: "t1".to_string(),
491                tweet_content: "A".to_string(),
492                reply_content: "B".to_string(),
493                reply_status: "sent".to_string(),
494                created_at: "2026-01-01T00:00:00Z".to_string(),
495                performance: Some(PerformanceSnapshot {
496                    likes: 10,
497                    replies_received: 3,
498                    impressions: 1000,
499                    performance_score: 90.0,
500                }),
501            },
502            ConversationRecord {
503                tweet_id: "t2".to_string(),
504                tweet_content: "C".to_string(),
505                reply_content: "D".to_string(),
506                reply_status: "sent".to_string(),
507                created_at: "2026-01-02T00:00:00Z".to_string(),
508                performance: Some(PerformanceSnapshot {
509                    likes: 3,
510                    replies_received: 0,
511                    impressions: 200,
512                    performance_score: 60.0,
513                }),
514            },
515        ];
516        let metrics = compute_response_metrics(&history);
517        assert_eq!(metrics.replies_with_engagement, 2);
518        assert!((metrics.response_rate - 1.0).abs() < 0.01);
519    }
520
521    // ============================================================
522    // generate_risk_signals tests
523    // ============================================================
524
525    #[test]
526    fn risk_no_prior_interaction() {
527        let signals = generate_risk_signals(&empty_summary(), &zero_metrics(), 5);
528        assert!(signals
529            .iter()
530            .any(|s| s.signal_type == "no_prior_interaction"));
531        assert_eq!(signals.len(), 1);
532    }
533
534    #[test]
535    fn risk_high_frequency_today() {
536        let mut summary = active_summary();
537        summary.replies_today = 5;
538        let signals = generate_risk_signals(&summary, &good_metrics(), 5);
539        assert!(signals
540            .iter()
541            .any(|s| s.signal_type == "high_frequency_today"));
542    }
543
544    #[test]
545    fn risk_low_response_rate() {
546        let summary = active_summary();
547        let signals = generate_risk_signals(&summary, &low_engagement_metrics(), 10);
548        assert!(signals.iter().any(|s| s.signal_type == "low_response_rate"));
549    }
550
551    #[test]
552    fn risk_no_measured_performance() {
553        let summary = active_summary();
554        let metrics = zero_metrics();
555        let signals = generate_risk_signals(&summary, &metrics, 10);
556        assert!(signals
557            .iter()
558            .any(|s| s.signal_type == "no_measured_performance"));
559    }
560
561    #[test]
562    fn risk_good_engagement_no_signals() {
563        let mut summary = active_summary();
564        summary.replies_today = 1;
565        let signals = generate_risk_signals(&summary, &good_metrics(), 10);
566        assert!(signals.is_empty(), "no risk signals expected: {signals:?}");
567    }
568
569    #[test]
570    fn risk_multiple_signals() {
571        let mut summary = active_summary();
572        summary.replies_today = 5;
573        let signals = generate_risk_signals(&summary, &low_engagement_metrics(), 5);
574        assert!(signals.len() >= 2, "expected multiple signals: {signals:?}");
575        assert!(signals
576            .iter()
577            .any(|s| s.signal_type == "high_frequency_today"));
578        assert!(signals.iter().any(|s| s.signal_type == "low_response_rate"));
579    }
580
581    // ============================================================
582    // DB-backed integration tests
583    // ============================================================
584
585    #[tokio::test]
586    async fn resolve_author_by_username() {
587        let pool = init_test_db().await.expect("init db");
588
589        // Seed a discovered tweet
590        let tweet = crate::storage::tweets::DiscoveredTweet {
591            id: "t_resolve".to_string(),
592            author_id: "uid_resolve".to_string(),
593            author_username: "resolveuser".to_string(),
594            content: "Test tweet".to_string(),
595            like_count: 5,
596            retweet_count: 1,
597            reply_count: 0,
598            impression_count: None,
599            relevance_score: Some(70.0),
600            matched_keyword: Some("test".to_string()),
601            discovered_at: "2026-02-20T10:00:00Z".to_string(),
602            replied_to: 0,
603        };
604        crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
605            .await
606            .expect("insert");
607
608        let (author_id, username) = resolve_author(&pool, "resolveuser", "resolveuser")
609            .await
610            .expect("resolve");
611        assert_eq!(author_id, Some("uid_resolve".to_string()));
612        assert_eq!(username, "resolveuser");
613    }
614
615    #[tokio::test]
616    async fn resolve_author_by_id() {
617        let pool = init_test_db().await.expect("init db");
618
619        let tweet = crate::storage::tweets::DiscoveredTweet {
620            id: "t_resolve_id".to_string(),
621            author_id: "uid_by_id".to_string(),
622            author_username: "byiduser".to_string(),
623            content: "Test".to_string(),
624            like_count: 0,
625            retweet_count: 0,
626            reply_count: 0,
627            impression_count: None,
628            relevance_score: None,
629            matched_keyword: None,
630            discovered_at: "2026-02-20T10:00:00Z".to_string(),
631            replied_to: 0,
632        };
633        crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
634            .await
635            .expect("insert");
636
637        let (author_id, username) = resolve_author(&pool, "uid_by_id", "uid_by_id")
638            .await
639            .expect("resolve");
640        assert_eq!(author_id, Some("uid_by_id".to_string()));
641        assert_eq!(username, "byiduser");
642    }
643
644    #[tokio::test]
645    async fn resolve_author_not_found() {
646        let pool = init_test_db().await.expect("init db");
647
648        let (author_id, username) = resolve_author(&pool, "nobody", "nobody")
649            .await
650            .expect("resolve");
651        assert!(author_id.is_none());
652        assert_eq!(username, "nobody");
653    }
654
655    #[tokio::test]
656    async fn interaction_summary_empty_db() {
657        let pool = init_test_db().await.expect("init db");
658
659        let summary = query_interaction_summary(&pool, &None, "nobody")
660            .await
661            .expect("summary");
662        assert_eq!(summary.total_replies_sent, 0);
663        assert_eq!(summary.replies_today, 0);
664        assert!(summary.first_interaction.is_none());
665    }
666
667    #[tokio::test]
668    async fn topic_affinity_empty_db() {
669        let pool = init_test_db().await.expect("init db");
670
671        let topics = query_topic_affinity(&pool, "nobody").await.expect("topics");
672        assert!(topics.is_empty());
673    }
674
675    #[tokio::test]
676    async fn topic_affinity_with_data() {
677        let pool = init_test_db().await.expect("init db");
678
679        // Seed multiple tweets with different keywords
680        for (i, kw) in ["rust", "rust", "async", "rust"].iter().enumerate() {
681            let tweet = crate::storage::tweets::DiscoveredTweet {
682                id: format!("ta_{i}"),
683                author_id: "uid_ta".to_string(),
684                author_username: "tauser".to_string(),
685                content: format!("Tweet about {kw}"),
686                like_count: 5,
687                retweet_count: 0,
688                reply_count: 0,
689                impression_count: None,
690                relevance_score: Some(70.0),
691                matched_keyword: Some(kw.to_string()),
692                discovered_at: "2026-02-20T10:00:00Z".to_string(),
693                replied_to: 0,
694            };
695            crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
696                .await
697                .expect("insert");
698        }
699
700        let topics = query_topic_affinity(&pool, "tauser").await.expect("topics");
701        assert_eq!(topics.len(), 2);
702        assert_eq!(topics[0].keyword, "rust");
703        assert_eq!(topics[0].mention_count, 3);
704        assert_eq!(topics[1].keyword, "async");
705        assert_eq!(topics[1].mention_count, 1);
706    }
707
708    #[tokio::test]
709    async fn conversation_history_empty() {
710        let pool = init_test_db().await.expect("init db");
711
712        let history = query_conversation_history(&pool, "nobody")
713            .await
714            .expect("history");
715        assert!(history.is_empty());
716    }
717
718    #[tokio::test]
719    async fn get_author_context_full_integration() {
720        let pool = init_test_db().await.expect("init db");
721        let config = crate::config::Config::default();
722
723        let ctx = get_author_context(&pool, "@testuser", &config)
724            .await
725            .expect("context");
726        assert_eq!(ctx.author_username, "testuser");
727        assert!(ctx.author_id.is_none());
728        assert_eq!(ctx.interaction_summary.total_replies_sent, 0);
729        // Should have "no_prior_interaction" signal
730        assert!(ctx
731            .risk_signals
732            .iter()
733            .any(|s| s.signal_type == "no_prior_interaction"));
734    }
735
736    #[tokio::test]
737    async fn get_author_context_with_seeded_data() {
738        let pool = init_test_db().await.expect("init db");
739        let config = crate::config::Config::default();
740
741        // Seed discovered tweet
742        let tweet = crate::storage::tweets::DiscoveredTweet {
743            id: "ctx_tweet".to_string(),
744            author_id: "uid_ctx".to_string(),
745            author_username: "ctxuser".to_string(),
746            content: "Context test tweet".to_string(),
747            like_count: 10,
748            retweet_count: 2,
749            reply_count: 1,
750            impression_count: Some(500),
751            relevance_score: Some(85.0),
752            matched_keyword: Some("rust".to_string()),
753            discovered_at: "2026-02-20T10:00:00Z".to_string(),
754            replied_to: 0,
755        };
756        crate::storage::tweets::insert_discovered_tweet(&pool, &tweet)
757            .await
758            .expect("insert");
759
760        let ctx = get_author_context(&pool, "ctxuser", &config)
761            .await
762            .expect("context");
763        assert_eq!(ctx.author_username, "ctxuser");
764        assert_eq!(ctx.author_id, Some("uid_ctx".to_string()));
765        assert_eq!(ctx.topic_affinity.len(), 1);
766        assert_eq!(ctx.topic_affinity[0].keyword, "rust");
767    }
768}