tuitbot_core/storage/
reset.rs1use super::DbPool;
8use crate::error::StorageError;
9
10#[derive(Debug, Clone, serde::Serialize)]
12pub struct ResetStats {
13 pub tables_cleared: u32,
15 pub rows_deleted: u64,
17}
18
19const TABLES_TO_CLEAR: &[&str] = &[
25 "draft_seeds",
27 "original_tweets",
28 "content_nodes",
29 "connections",
30 "thread_tweets",
31 "account_roles",
32 "target_tweets",
33 "approval_edit_history",
34 "reply_performance",
36 "tweet_performance",
37 "replies_sent",
38 "discovered_tweets",
39 "threads",
40 "approval_queue",
41 "scheduled_content",
42 "target_accounts",
43 "follower_snapshots",
44 "content_scores",
45 "strategy_reports",
46 "rate_limits",
47 "action_log",
48 "cursors",
49 "author_interactions",
50 "media_uploads",
51 "llm_usage",
52 "x_api_usage",
53 "mcp_telemetry",
54 "mutation_audit",
55 "source_contexts",
56 "sessions",
57 "accounts",
58];
59
60pub async fn factory_reset(pool: &DbPool) -> Result<ResetStats, StorageError> {
68 let mut tx = pool
69 .begin()
70 .await
71 .map_err(|e| StorageError::Connection { source: e })?;
72
73 let mut rows_deleted: u64 = 0;
74 let mut tables_cleared: u32 = 0;
75
76 for table in TABLES_TO_CLEAR {
77 let query = format!("DELETE FROM {table}");
78 let result = sqlx::query(&query)
79 .execute(&mut *tx)
80 .await
81 .map_err(|e| StorageError::Query { source: e })?;
82 rows_deleted += result.rows_affected();
83 tables_cleared += 1;
84 }
85
86 tx.commit()
87 .await
88 .map_err(|e| StorageError::Connection { source: e })?;
89
90 Ok(ResetStats {
91 tables_cleared,
92 rows_deleted,
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::storage::init_test_db;
100
101 #[tokio::test]
102 async fn factory_reset_clears_all_tables() {
103 let pool = init_test_db().await.expect("init test db");
104
105 sqlx::query("INSERT INTO accounts (id, label) VALUES ('acc1', 'Extra')")
107 .execute(&pool)
108 .await
109 .unwrap();
110
111 sqlx::query(
112 "INSERT INTO account_roles (account_id, actor, role) \
113 VALUES ('acc1', 'dashboard', 'composer')",
114 )
115 .execute(&pool)
116 .await
117 .unwrap();
118
119 sqlx::query("INSERT INTO target_accounts (account_id, username) VALUES ('t1', 'target1')")
120 .execute(&pool)
121 .await
122 .unwrap();
123
124 sqlx::query(
125 "INSERT INTO discovered_tweets (id, author_id, author_username, content) \
126 VALUES ('tw1', 'auth1', 'someone', 'hello world')",
127 )
128 .execute(&pool)
129 .await
130 .unwrap();
131
132 let stats = factory_reset(&pool).await.expect("factory reset");
134 assert_eq!(stats.tables_cleared, 31);
135 assert!(stats.rows_deleted >= 7);
137
138 for table in TABLES_TO_CLEAR {
140 let count: (i64,) = sqlx::query_as(&format!("SELECT COUNT(*) FROM {table}"))
141 .fetch_one(&pool)
142 .await
143 .unwrap();
144 assert_eq!(count.0, 0, "table {table} should be empty after reset");
145 }
146 }
147
148 #[tokio::test]
149 async fn factory_reset_preserves_migrations() {
150 let pool = init_test_db().await.expect("init test db");
151
152 let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
153 .fetch_one(&pool)
154 .await
155 .unwrap();
156 assert!(before.0 > 0, "migrations table should have entries");
157
158 factory_reset(&pool).await.expect("factory reset");
159
160 let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
161 .fetch_one(&pool)
162 .await
163 .unwrap();
164 assert_eq!(before.0, after.0, "migrations should be untouched");
165 }
166
167 #[tokio::test]
168 async fn factory_reset_returns_accurate_stats() {
169 let pool = init_test_db().await.expect("init test db");
170
171 factory_reset(&pool).await.expect("pre-clear");
173
174 sqlx::query("INSERT INTO accounts (id, label) VALUES ('a1', 'U1')")
176 .execute(&pool)
177 .await
178 .unwrap();
179 sqlx::query("INSERT INTO accounts (id, label) VALUES ('a2', 'U2')")
180 .execute(&pool)
181 .await
182 .unwrap();
183
184 let stats = factory_reset(&pool).await.expect("factory reset");
185 assert_eq!(stats.tables_cleared, 31);
186 assert_eq!(stats.rows_deleted, 2);
187 }
188
189 #[tokio::test]
190 async fn factory_reset_idempotent() {
191 let pool = init_test_db().await.expect("init test db");
192
193 let stats1 = factory_reset(&pool).await.expect("first reset");
195 assert_eq!(stats1.tables_cleared, 31);
196 assert_eq!(stats1.rows_deleted, 3);
198
199 let stats2 = factory_reset(&pool).await.expect("second reset");
201 assert_eq!(stats2.tables_cleared, 31);
202 assert_eq!(stats2.rows_deleted, 0);
203 }
204
205 #[tokio::test]
206 async fn tables_to_clear_covers_all_user_tables() {
207 let pool = init_test_db().await.expect("init test db");
208
209 let all_tables: Vec<(String,)> = sqlx::query_as(
211 "SELECT name FROM sqlite_master \
212 WHERE type='table' \
213 AND name NOT LIKE 'sqlite_%' \
214 AND name != '_sqlx_migrations' \
215 ORDER BY name",
216 )
217 .fetch_all(&pool)
218 .await
219 .unwrap();
220
221 let table_names: Vec<&str> = all_tables.iter().map(|t| t.0.as_str()).collect();
222
223 for name in &table_names {
225 assert!(
226 TABLES_TO_CLEAR.contains(name),
227 "table '{name}' exists in DB but is missing from TABLES_TO_CLEAR"
228 );
229 }
230
231 for name in TABLES_TO_CLEAR {
233 assert!(
234 table_names.contains(name),
235 "table '{name}' is in TABLES_TO_CLEAR but does not exist in DB"
236 );
237 }
238 }
239}