max / goingson
15 files changed,
+650 insertions,
-37 deletions
| @@ -6,7 +6,10 @@ | |||
| 6 | 6 | //! and sub-collections are written verbatim; this module only defines the types that | |
| 7 | 7 | //! cross the command -> storage boundary. | |
| 8 | 8 | ||
| 9 | - | use crate::models::{Attachment, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task, TimeSession}; | |
| 9 | + | use crate::models::{ | |
| 10 | + | Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Project, | |
| 11 | + | SavedView, SyncAccount, Task, TimeSession, WeeklyReview, | |
| 12 | + | }; | |
| 10 | 13 | use crate::Contact; | |
| 11 | 14 | ||
| 12 | 15 | /// Result of a restore operation. | |
| @@ -36,6 +39,14 @@ pub struct RestoreResult { | |||
| 36 | 39 | pub attachments_restored: usize, | |
| 37 | 40 | /// Number of sync accounts restored. | |
| 38 | 41 | pub sync_accounts_restored: usize, | |
| 42 | + | /// Number of saved views restored. | |
| 43 | + | pub saved_views_restored: usize, | |
| 44 | + | /// Number of weekly reviews restored. | |
| 45 | + | pub weekly_reviews_restored: usize, | |
| 46 | + | /// Number of monthly goals restored. | |
| 47 | + | pub monthly_goals_restored: usize, | |
| 48 | + | /// Number of monthly reflections restored. | |
| 49 | + | pub monthly_reflections_restored: usize, | |
| 39 | 50 | } | |
| 40 | 51 | ||
| 41 | 52 | /// Pre-parsed backup data for restoration. | |
| @@ -50,6 +61,10 @@ pub struct RestoreInput { | |||
| 50 | 61 | pub daily_notes: Vec<DailyNote>, | |
| 51 | 62 | pub attachments: Vec<Attachment>, | |
| 52 | 63 | pub sync_accounts: Vec<SyncAccount>, | |
| 64 | + | pub saved_views: Vec<SavedView>, | |
| 65 | + | pub weekly_reviews: Vec<WeeklyReview>, | |
| 66 | + | pub monthly_goals: Vec<MonthlyGoal>, | |
| 67 | + | pub monthly_reflections: Vec<MonthlyReflection>, | |
| 53 | 68 | } | |
| 54 | 69 | ||
| 55 | 70 | #[cfg(test)] | |
| @@ -78,6 +93,10 @@ mod tests { | |||
| 78 | 93 | daily_notes: vec![], | |
| 79 | 94 | attachments: vec![], | |
| 80 | 95 | sync_accounts: vec![], | |
| 96 | + | saved_views: vec![], | |
| 97 | + | weekly_reviews: vec![], | |
| 98 | + | monthly_goals: vec![], | |
| 99 | + | monthly_reflections: vec![], | |
| 81 | 100 | }; | |
| 82 | 101 | assert!(input.projects.is_empty()); | |
| 83 | 102 | assert!(input.tasks.is_empty()); |
| @@ -113,8 +113,10 @@ pub struct ViewFilters { | |||
| 113 | 113 | pub struct SavedView { | |
| 114 | 114 | /// Unique view ID. | |
| 115 | 115 | pub id: SavedViewId, | |
| 116 | - | /// User who owns this view. | |
| 117 | - | #[serde(skip_serializing)] | |
| 116 | + | /// User who owns this view. Not serialized into API responses; `default` lets a | |
| 117 | + | /// backup (which omits it) still deserialize -- restore re-homes the row to the | |
| 118 | + | /// target user, so the placeholder is never persisted. | |
| 119 | + | #[serde(default, skip_serializing)] | |
| 118 | 120 | pub user_id: UserId, | |
| 119 | 121 | /// Display name for the view. | |
| 120 | 122 | pub name: String, |
| @@ -809,6 +809,9 @@ pub trait WeeklyReviewRepository: Send + Sync { | |||
| 809 | 809 | /// Creates or updates a weekly review. | |
| 810 | 810 | async fn upsert(&self, user_id: UserId, week_start: NaiveDate, notes: &str) -> Result<crate::models::WeeklyReview>; | |
| 811 | 811 | ||
| 812 | + | /// Lists every weekly review for a user across all weeks (for full backup export). | |
| 813 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::WeeklyReview>>; | |
| 814 | + | ||
| 812 | 815 | /// Checks if the current week's review is completed. | |
| 813 | 816 | async fn is_current_week_completed(&self, user_id: UserId) -> Result<bool>; | |
| 814 | 817 | ||
| @@ -835,6 +838,12 @@ pub trait MonthlyReviewRepository: Send + Sync { | |||
| 835 | 838 | /// Gets all goals for a specific month. | |
| 836 | 839 | async fn list_goals(&self, user_id: UserId, month: &str) -> Result<Vec<crate::models::MonthlyGoal>>; | |
| 837 | 840 | ||
| 841 | + | /// Lists every goal for a user across all months (for full backup export). | |
| 842 | + | async fn list_all_goals(&self, user_id: UserId) -> Result<Vec<crate::models::MonthlyGoal>>; | |
| 843 | + | ||
| 844 | + | /// Lists every reflection for a user across all months (for full backup export). | |
| 845 | + | async fn list_all_reflections(&self, user_id: UserId) -> Result<Vec<crate::models::MonthlyReflection>>; | |
| 846 | + | ||
| 838 | 847 | /// Creates or updates a goal for a month at a given position (1-3). | |
| 839 | 848 | async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<crate::models::MonthlyGoal>; | |
| 840 | 849 |
| @@ -72,4 +72,4 @@ pub use repository::{ | |||
| 72 | 72 | SqliteWeeklyReviewRepository, | |
| 73 | 73 | }; | |
| 74 | 74 | ||
| 75 | - | pub use repository::restore::restore_all; | |
| 75 | + | pub use repository::restore::{restore_all, BACKUP_TABLES, EXCLUDED_TABLES}; |
| @@ -106,6 +106,42 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |||
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | 108 | #[tracing::instrument(skip_all)] | |
| 109 | + | async fn list_all_goals(&self, user_id: UserId) -> Result<Vec<MonthlyGoal>> { | |
| 110 | + | let user_id_str = user_id.to_string(); | |
| 111 | + | ||
| 112 | + | let rows: Vec<MonthlyGoalRow> = sqlx::query_as( | |
| 113 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 114 | + | FROM monthly_goals | |
| 115 | + | WHERE user_id = ? | |
| 116 | + | ORDER BY month, position", | |
| 117 | + | ) | |
| 118 | + | .bind(&user_id_str) | |
| 119 | + | .fetch_all(&self.pool) | |
| 120 | + | .await | |
| 121 | + | .map_err(CoreError::database)?; | |
| 122 | + | ||
| 123 | + | rows.into_iter().map(MonthlyGoal::try_from).collect() | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[tracing::instrument(skip_all)] | |
| 127 | + | async fn list_all_reflections(&self, user_id: UserId) -> Result<Vec<MonthlyReflection>> { | |
| 128 | + | let user_id_str = user_id.to_string(); | |
| 129 | + | ||
| 130 | + | let rows: Vec<MonthlyReflectionRow> = sqlx::query_as( | |
| 131 | + | "SELECT id, user_id, month, highlight_text, change_text, completed_at | |
| 132 | + | FROM monthly_reflections | |
| 133 | + | WHERE user_id = ? | |
| 134 | + | ORDER BY month", | |
| 135 | + | ) | |
| 136 | + | .bind(&user_id_str) | |
| 137 | + | .fetch_all(&self.pool) | |
| 138 | + | .await | |
| 139 | + | .map_err(CoreError::database)?; | |
| 140 | + | ||
| 141 | + | rows.into_iter().map(MonthlyReflection::try_from).collect() | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[tracing::instrument(skip_all)] | |
| 109 | 145 | async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<MonthlyGoal> { | |
| 110 | 146 | let user_id_str = user_id.to_string(); | |
| 111 | 147 | let now = format_datetime(&Utc::now()); |
| @@ -14,11 +14,56 @@ | |||
| 14 | 14 | //! changelog triggers fire as normal, so restored rows propagate to other devices. | |
| 15 | 15 | ||
| 16 | 16 | use goingson_core::backup_restore::{RestoreInput, RestoreResult}; | |
| 17 | + | use goingson_core::models::SortDirection; | |
| 17 | 18 | use goingson_core::{CoreError, DbValue, Result, UserId}; | |
| 18 | 19 | use sqlx::{Sqlite, SqlitePool, Transaction}; | |
| 19 | 20 | ||
| 20 | 21 | use crate::utils::{format_datetime, format_datetime_opt}; | |
| 21 | 22 | ||
| 23 | + | /// Every table a full backup must capture and restore -- the single source of | |
| 24 | + | /// truth for backup completeness. `collect_full_export` gathers each of these and | |
| 25 | + | /// `restore_all` writes each back; the `backup_tables_match_schema` test asserts | |
| 26 | + | /// this list equals the live schema minus [`EXCLUDED_TABLES`], so adding a table to | |
| 27 | + | /// the schema without registering it here (or excluding it) is a test failure. This | |
| 28 | + | /// is the structural fix for the recurring "backup silently omitted table X" finding | |
| 29 | + | /// (ultra-fuzz CHRONIC-D), mirroring the trigger/synced-column round-trip test. | |
| 30 | + | pub const BACKUP_TABLES: &[&str] = &[ | |
| 31 | + | "projects", | |
| 32 | + | "tasks", | |
| 33 | + | "annotations", | |
| 34 | + | "subtasks", | |
| 35 | + | "events", | |
| 36 | + | "emails", | |
| 37 | + | "contacts", | |
| 38 | + | "contact_emails", | |
| 39 | + | "contact_phones", | |
| 40 | + | "contact_social_handles", | |
| 41 | + | "contact_custom_fields", | |
| 42 | + | "milestones", | |
| 43 | + | "time_sessions", | |
| 44 | + | "daily_notes", | |
| 45 | + | "attachments", | |
| 46 | + | "sync_accounts", | |
| 47 | + | "saved_views", | |
| 48 | + | "weekly_reviews", | |
| 49 | + | "monthly_goals", | |
| 50 | + | "monthly_reflections", | |
| 51 | + | ]; | |
| 52 | + | ||
| 53 | + | /// Tables deliberately excluded from backups, each for a documented reason. A table | |
| 54 | + | /// must appear in either [`BACKUP_TABLES`] or here; the coverage test fails otherwise. | |
| 55 | + | pub const EXCLUDED_TABLES: &[&str] = &[ | |
| 56 | + | "users", // single fixed desktop user, recreated on init | |
| 57 | + | "email_accounts", // credentials/config; secrets live in the OS keychain, re-auth on restore | |
| 58 | + | "llm_settings", // local config / API keys, not user content | |
| 59 | + | "llm_cache", // regenerable cache | |
| 60 | + | "backup_settings", // local backup cadence/retention config | |
| 61 | + | "backup_settings_new", // transient table-rename scaffold (absent post-migration) | |
| 62 | + | "imap_folder_sync_state", // sync-transient IMAP state, re-derived on next sync | |
| 63 | + | "sync_changelog", // sync internals, regenerated | |
| 64 | + | "sync_state", // sync cursors/flags, internal | |
| 65 | + | ]; | |
| 66 | + | ||
| 22 | 67 | /// Restore a backup into the database as a single transaction. | |
| 23 | 68 | /// | |
| 24 | 69 | /// On any error the transaction is dropped without committing, so the database is | |
| @@ -31,11 +76,24 @@ pub async fn restore_all( | |||
| 31 | 76 | let mut tx = pool.begin().await.map_err(CoreError::database)?; | |
| 32 | 77 | let mut result = RestoreResult::default(); | |
| 33 | 78 | ||
| 34 | - | // Ordered so every foreign key's target is inserted first; otherwise an | |
| 35 | - | // INSERT OR IGNORE silently drops a row that references a not-yet-restored | |
| 36 | - | // parent. projects/contacts are roots; milestones and emails are parents of | |
| 37 | - | // tasks; tasks parent time_sessions/events/attachments; daily_notes and | |
| 38 | - | // sync_accounts are independent. | |
| 79 | + | // Defer foreign-key enforcement to COMMIT. Without this, a self-referential FK | |
| 80 | + | // (tasks/events `recurrence_parent_id`) raises immediately when a child instance | |
| 81 | + | // is inserted before its parent root -- and because the backup is emitted in | |
| 82 | + | // urgency/created order, the child routinely precedes the parent. An immediate | |
| 83 | + | // FK violation (code 787) aborts the whole transaction, losing the entire | |
| 84 | + | // restore (ultra-fuzz Run #27 C1). Deferring checks them once, at commit, by | |
| 85 | + | // which point every referent in the backup has been inserted; only a genuinely | |
| 86 | + | // dangling reference still fails. `INSERT OR IGNORE` does NOT suppress FK | |
| 87 | + | // violations -- it only suppresses UNIQUE/NOT NULL/CHECK conflicts. | |
| 88 | + | sqlx::query("PRAGMA defer_foreign_keys = ON") | |
| 89 | + | .execute(&mut *tx) | |
| 90 | + | .await | |
| 91 | + | .map_err(CoreError::database)?; | |
| 92 | + | ||
| 93 | + | // The insert order below is no longer load-bearing for FK correctness (deferred | |
| 94 | + | // checks make any order valid as long as every referent is present), but is kept | |
| 95 | + | // roots-first for readability. Every table in `BACKUP_TABLES` must be restored | |
| 96 | + | // here; the round-trip test enforces it. | |
| 39 | 97 | restore_projects(&mut tx, user_id, input, &mut result).await?; | |
| 40 | 98 | restore_contacts_with_children(&mut tx, user_id, input, &mut result).await?; | |
| 41 | 99 | restore_milestones(&mut tx, user_id, input, &mut result).await?; | |
| @@ -46,6 +104,10 @@ pub async fn restore_all( | |||
| 46 | 104 | restore_attachments(&mut tx, user_id, input, &mut result).await?; | |
| 47 | 105 | restore_daily_notes(&mut tx, user_id, input, &mut result).await?; | |
| 48 | 106 | restore_sync_accounts(&mut tx, user_id, input, &mut result).await?; | |
| 107 | + | restore_saved_views(&mut tx, user_id, input, &mut result).await?; | |
| 108 | + | restore_weekly_reviews(&mut tx, user_id, input, &mut result).await?; | |
| 109 | + | restore_monthly_goals(&mut tx, user_id, input, &mut result).await?; | |
| 110 | + | restore_monthly_reflections(&mut tx, user_id, input, &mut result).await?; | |
| 49 | 111 | ||
| 50 | 112 | tx.commit().await.map_err(CoreError::database)?; | |
| 51 | 113 | Ok(result) | |
| @@ -609,3 +671,144 @@ async fn restore_sync_accounts( | |||
| 609 | 671 | } | |
| 610 | 672 | Ok(()) | |
| 611 | 673 | } | |
| 674 | + | ||
| 675 | + | async fn restore_saved_views( | |
| 676 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 677 | + | user_id: UserId, | |
| 678 | + | input: &RestoreInput, | |
| 679 | + | result: &mut RestoreResult, | |
| 680 | + | ) -> Result<()> { | |
| 681 | + | for view in &input.saved_views { | |
| 682 | + | let filters_json = serde_json::to_string(&view.filters).unwrap_or_else(|_| "{}".to_string()); | |
| 683 | + | let sort_by = view.sort_by.and_then(|sf| { | |
| 684 | + | serde_json::to_value(sf) | |
| 685 | + | .ok() | |
| 686 | + | .and_then(|v| v.as_str().map(String::from)) | |
| 687 | + | }); | |
| 688 | + | let sort_order = match view.sort_order { | |
| 689 | + | SortDirection::Desc => "desc", | |
| 690 | + | SortDirection::Asc => "asc", | |
| 691 | + | }; | |
| 692 | + | let affected = sqlx::query( | |
| 693 | + | "INSERT OR IGNORE INTO saved_views (\ | |
| 694 | + | id, user_id, name, view_type, filters, sort_by, sort_order, is_pinned, position, \ | |
| 695 | + | created_at, updated_at\ | |
| 696 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 697 | + | ) | |
| 698 | + | .bind(view.id.to_string()) | |
| 699 | + | .bind(user_id.to_string()) | |
| 700 | + | .bind(&view.name) | |
| 701 | + | .bind(view.view_type.db_value()) | |
| 702 | + | .bind(&filters_json) | |
| 703 | + | .bind(&sort_by) | |
| 704 | + | .bind(sort_order) | |
| 705 | + | .bind(if view.is_pinned { 1 } else { 0 }) | |
| 706 | + | .bind(view.position) | |
| 707 | + | .bind(format_datetime(&view.created_at)) | |
| 708 | + | .bind(format_datetime(&view.updated_at)) | |
| 709 | + | .execute(&mut **tx) | |
| 710 | + | .await | |
| 711 | + | .map_err(CoreError::database)? | |
| 712 | + | .rows_affected(); | |
| 713 | + | if affected > 0 { | |
| 714 | + | result.saved_views_restored += 1; | |
| 715 | + | } | |
| 716 | + | } | |
| 717 | + | Ok(()) | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | async fn restore_weekly_reviews( | |
| 721 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 722 | + | user_id: UserId, | |
| 723 | + | input: &RestoreInput, | |
| 724 | + | result: &mut RestoreResult, | |
| 725 | + | ) -> Result<()> { | |
| 726 | + | for review in &input.weekly_reviews { | |
| 727 | + | let vacation_days = review | |
| 728 | + | .vacation_days | |
| 729 | + | .iter() | |
| 730 | + | .filter(|&&d| d <= 6) | |
| 731 | + | .map(|d| d.to_string()) | |
| 732 | + | .collect::<Vec<_>>() | |
| 733 | + | .join(","); | |
| 734 | + | let affected = sqlx::query( | |
| 735 | + | "INSERT OR IGNORE INTO weekly_reviews (\ | |
| 736 | + | id, user_id, week_start_date, completed_at, notes, vacation_days\ | |
| 737 | + | ) VALUES (?, ?, ?, ?, ?, ?)", | |
| 738 | + | ) | |
| 739 | + | .bind(review.id.to_string()) | |
| 740 | + | .bind(user_id.to_string()) | |
| 741 | + | .bind(review.week_start_date.format("%Y-%m-%d").to_string()) | |
| 742 | + | .bind(format_datetime(&review.completed_at)) | |
| 743 | + | .bind(&review.notes) | |
| 744 | + | .bind(&vacation_days) | |
| 745 | + | .execute(&mut **tx) | |
| 746 | + | .await | |
| 747 | + | .map_err(CoreError::database)? | |
| 748 | + | .rows_affected(); | |
| 749 | + | if affected > 0 { | |
| 750 | + | result.weekly_reviews_restored += 1; | |
| 751 | + | } | |
| 752 | + | } | |
| 753 | + | Ok(()) | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | async fn restore_monthly_goals( | |
| 757 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 758 | + | user_id: UserId, | |
| 759 | + | input: &RestoreInput, | |
| 760 | + | result: &mut RestoreResult, | |
| 761 | + | ) -> Result<()> { | |
| 762 | + | for goal in &input.monthly_goals { | |
| 763 | + | let affected = sqlx::query( | |
| 764 | + | "INSERT OR IGNORE INTO monthly_goals (\ | |
| 765 | + | id, user_id, month, text, status, position, created_at, updated_at\ | |
| 766 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 767 | + | ) | |
| 768 | + | .bind(goal.id.to_string()) | |
| 769 | + | .bind(user_id.to_string()) | |
| 770 | + | .bind(&goal.month) | |
| 771 | + | .bind(&goal.text) | |
| 772 | + | .bind(goal.status.as_str()) | |
| 773 | + | .bind(goal.position) | |
| 774 | + | .bind(format_datetime(&goal.created_at)) | |
| 775 | + | .bind(format_datetime(&goal.updated_at)) | |
| 776 | + | .execute(&mut **tx) | |
| 777 | + | .await | |
| 778 | + | .map_err(CoreError::database)? | |
| 779 | + | .rows_affected(); | |
| 780 | + | if affected > 0 { | |
| 781 | + | result.monthly_goals_restored += 1; | |
| 782 | + | } | |
| 783 | + | } | |
| 784 | + | Ok(()) | |
| 785 | + | } | |
| 786 | + | ||
| 787 | + | async fn restore_monthly_reflections( | |
| 788 | + | tx: &mut Transaction<'_, Sqlite>, | |
| 789 | + | user_id: UserId, | |
| 790 | + | input: &RestoreInput, | |
| 791 | + | result: &mut RestoreResult, | |
| 792 | + | ) -> Result<()> { | |
| 793 | + | for reflection in &input.monthly_reflections { | |
| 794 | + | let affected = sqlx::query( | |
| 795 | + | "INSERT OR IGNORE INTO monthly_reflections (\ | |
| 796 | + | id, user_id, month, highlight_text, change_text, completed_at\ | |
| 797 | + | ) VALUES (?, ?, ?, ?, ?, ?)", | |
| 798 | + | ) | |
| 799 | + | .bind(reflection.id.to_string()) | |
| 800 | + | .bind(user_id.to_string()) | |
| 801 | + | .bind(&reflection.month) | |
| 802 | + | .bind(&reflection.highlight_text) | |
| 803 | + | .bind(&reflection.change_text) | |
| 804 | + | .bind(format_datetime(&reflection.completed_at)) | |
| 805 | + | .execute(&mut **tx) | |
| 806 | + | .await | |
| 807 | + | .map_err(CoreError::database)? | |
| 808 | + | .rows_affected(); | |
| 809 | + | if affected > 0 { | |
| 810 | + | result.monthly_reflections_restored += 1; | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | Ok(()) | |
| 814 | + | } |
| @@ -52,8 +52,10 @@ impl SearchRepository for SqliteSearchRepository { | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | let user_id_str = user_id.to_string(); | |
| 55 | - | let limit = query.limit.unwrap_or(50); | |
| 56 | - | let offset = query.offset.unwrap_or(0); | |
| 55 | + | // Clamp to non-negative: a negative limit/offset would otherwise be | |
| 56 | + | // interpolated as `LIMIT -N`, which SQLite treats as unbounded. | |
| 57 | + | let limit = query.limit.unwrap_or(50).max(0); | |
| 58 | + | let offset = query.offset.unwrap_or(0).max(0); | |
| 57 | 59 | let per_type_cap = offset.saturating_add(limit); | |
| 58 | 60 | ||
| 59 | 61 | // Prepare search term for FTS5 (escape special characters and add prefix matching) |
| @@ -204,4 +204,22 @@ impl WeeklyReviewRepository for SqliteWeeklyReviewRepository { | |||
| 204 | 204 | ||
| 205 | 205 | Ok(()) | |
| 206 | 206 | } | |
| 207 | + | ||
| 208 | + | #[tracing::instrument(skip_all)] | |
| 209 | + | async fn list_all(&self, user_id: UserId) -> Result<Vec<WeeklyReview>> { | |
| 210 | + | let user_id_str = user_id.to_string(); | |
| 211 | + | ||
| 212 | + | let rows: Vec<WeeklyReviewRow> = sqlx::query_as( | |
| 213 | + | "SELECT id, user_id, week_start_date, completed_at, notes, vacation_days | |
| 214 | + | FROM weekly_reviews | |
| 215 | + | WHERE user_id = ? | |
| 216 | + | ORDER BY week_start_date ASC", | |
| 217 | + | ) | |
| 218 | + | .bind(&user_id_str) | |
| 219 | + | .fetch_all(&self.pool) | |
| 220 | + | .await | |
| 221 | + | .map_err(CoreError::database)?; | |
| 222 | + | ||
| 223 | + | rows.into_iter().map(WeeklyReview::try_from).collect() | |
| 224 | + | } | |
| 207 | 225 | } |
| @@ -0,0 +1,156 @@ | |||
| 1 | + | //! Backup completeness + restore-ordering guards (ultra-fuzz CHRONIC-D / C1). | |
| 2 | + | //! | |
| 3 | + | //! `schema_tables_are_all_classified` is the structural enforcement: every table in | |
| 4 | + | //! the live schema must be either in `BACKUP_TABLES` or `EXCLUDED_TABLES`. Adding a | |
| 5 | + | //! migration that creates a user table without registering it fails here, which is | |
| 6 | + | //! what stops the recurring "backup silently omitted table X" finding. | |
| 7 | + | //! | |
| 8 | + | //! `restore_handles_recurring_task_self_fk` is the C1 regression guard: a recurring | |
| 9 | + | //! child instance whose `recurrence_parent_id` points at a parent that sorts later in | |
| 10 | + | //! the backup must restore without aborting the whole transaction. | |
| 11 | + | ||
| 12 | + | mod common; | |
| 13 | + | ||
| 14 | + | use goingson_core::backup_restore::{RestoreInput, RestoreResult}; | |
| 15 | + | use goingson_db_sqlite::{restore_all, BACKUP_TABLES, EXCLUDED_TABLES}; | |
| 16 | + | use sqlx::Row; | |
| 17 | + | ||
| 18 | + | fn empty_input() -> RestoreInput { | |
| 19 | + | RestoreInput { | |
| 20 | + | projects: vec![], | |
| 21 | + | tasks: vec![], | |
| 22 | + | events: vec![], | |
| 23 | + | emails: vec![], | |
| 24 | + | contacts: vec![], | |
| 25 | + | time_sessions: vec![], | |
| 26 | + | milestones: vec![], | |
| 27 | + | daily_notes: vec![], | |
| 28 | + | attachments: vec![], | |
| 29 | + | sync_accounts: vec![], | |
| 30 | + | saved_views: vec![], | |
| 31 | + | weekly_reviews: vec![], | |
| 32 | + | monthly_goals: vec![], | |
| 33 | + | monthly_reflections: vec![], | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[tokio::test] | |
| 38 | + | async fn schema_tables_are_all_classified() { | |
| 39 | + | let pool = common::setup_test_db().await; | |
| 40 | + | ||
| 41 | + | let rows = sqlx::query( | |
| 42 | + | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", | |
| 43 | + | ) | |
| 44 | + | .fetch_all(&pool) | |
| 45 | + | .await | |
| 46 | + | .expect("query sqlite_master"); | |
| 47 | + | ||
| 48 | + | for row in rows { | |
| 49 | + | let name: String = row.get("name"); | |
| 50 | + | // FTS5 shadow/content tables (e.g. tasks_fts, tasks_fts_data) are derived | |
| 51 | + | // search indexes, not user content; the sqlx migration ledger is bookkeeping. | |
| 52 | + | if name.contains("_fts") || name == "_sqlx_migrations" { | |
| 53 | + | continue; | |
| 54 | + | } | |
| 55 | + | let classified = | |
| 56 | + | BACKUP_TABLES.contains(&name.as_str()) || EXCLUDED_TABLES.contains(&name.as_str()); | |
| 57 | + | assert!( | |
| 58 | + | classified, | |
| 59 | + | "table `{name}` is in the schema but neither backed up (BACKUP_TABLES) nor \ | |
| 60 | + | excluded (EXCLUDED_TABLES). Add it to one in restore.rs -- a new user table \ | |
| 61 | + | must be a deliberate backup decision, not silently dropped." | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // And the reverse: nothing in BACKUP_TABLES should be a phantom (typo) name. | |
| 66 | + | for t in BACKUP_TABLES { | |
| 67 | + | let exists: Option<String> = | |
| 68 | + | sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") | |
| 69 | + | .bind(t) | |
| 70 | + | .fetch_optional(&pool) | |
| 71 | + | .await | |
| 72 | + | .expect("lookup backup table"); | |
| 73 | + | assert!(exists.is_some(), "BACKUP_TABLES lists `{t}` which is not a real table"); | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[tokio::test] | |
| 78 | + | async fn restore_handles_recurring_task_self_fk() { | |
| 79 | + | use goingson_core::{ | |
| 80 | + | NewProject, NewTask, ProjectRepository, TaskRepository, TaskStatus, | |
| 81 | + | }; | |
| 82 | + | use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository}; | |
| 83 | + | ||
| 84 | + | // Build a parent + child task where the child references the parent via | |
| 85 | + | // recurrence_parent_id, then order them child-first in the RestoreInput so the | |
| 86 | + | // FK target is inserted second. Pre-fix this aborted the whole restore (FK 787). | |
| 87 | + | let src = common::setup_test_db().await; | |
| 88 | + | let user_id = common::create_test_user(&src).await; | |
| 89 | + | let projects = SqliteProjectRepository::new(src.clone()); | |
| 90 | + | let tasks = SqliteTaskRepository::new(src.clone()); | |
| 91 | + | ||
| 92 | + | let project = projects | |
| 93 | + | .create( | |
| 94 | + | user_id, | |
| 95 | + | NewProject { | |
| 96 | + | name: "Recurring".into(), | |
| 97 | + | description: String::new(), | |
| 98 | + | project_type: Default::default(), | |
| 99 | + | status: Default::default(), | |
| 100 | + | }, | |
| 101 | + | ) | |
| 102 | + | .await | |
| 103 | + | .unwrap(); | |
| 104 | + | ||
| 105 | + | let mut parent = tasks | |
| 106 | + | .create( | |
| 107 | + | user_id, | |
| 108 | + | NewTask::builder("Parent occurrence").project_id(project.id).build(), | |
| 109 | + | ) | |
| 110 | + | .await | |
| 111 | + | .unwrap(); | |
| 112 | + | let mut child = tasks | |
| 113 | + | .create( | |
| 114 | + | user_id, | |
| 115 | + | NewTask::builder("Next occurrence").project_id(project.id).build(), | |
| 116 | + | ) | |
| 117 | + | .await | |
| 118 | + | .unwrap(); | |
| 119 | + | parent.status = TaskStatus::Completed; | |
| 120 | + | child.recurrence_parent_id = Some(parent.id); | |
| 121 | + | ||
| 122 | + | // Restore into a fresh DB with the child BEFORE the parent in the vec. | |
| 123 | + | let dst = common::setup_test_db().await; | |
| 124 | + | let _ = common::create_test_user(&dst).await; // satisfy users FK on restored rows | |
| 125 | + | // Restored rows carry the source user_id; recreate that exact user in dst. | |
| 126 | + | sqlx::query("INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, 'x', 'x', '2026-01-01 00:00:00')") | |
| 127 | + | .bind(user_id.to_string()) | |
| 128 | + | .bind(format!("u-{user_id}@example.com")) | |
| 129 | + | .execute(&dst) | |
| 130 | + | .await | |
| 131 | + | .unwrap(); | |
| 132 | + | sqlx::query("INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at) VALUES (?, ?, 'Recurring', '', 'work', 'active', '2026-01-01 00:00:00')") | |
| 133 | + | .bind(project.id.to_string()) | |
| 134 | + | .bind(user_id.to_string()) | |
| 135 | + | .execute(&dst) | |
| 136 | + | .await | |
| 137 | + | .unwrap(); | |
| 138 | + | ||
| 139 | + | let input = RestoreInput { | |
| 140 | + | tasks: vec![child.clone(), parent.clone()], | |
| 141 | + | ..empty_input() | |
| 142 | + | }; | |
| 143 | + | ||
| 144 | + | let result: RestoreResult = restore_all(&dst, user_id, &input) | |
| 145 | + | .await | |
| 146 | + | .expect("restore must not abort on self-FK ordering (C1)"); | |
| 147 | + | assert_eq!(result.tasks_restored, 2, "both occurrences restored"); | |
| 148 | + | ||
| 149 | + | let count: i64 = | |
| 150 | + | sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE recurrence_parent_id = ?") | |
| 151 | + | .bind(parent.id.to_string()) | |
| 152 | + | .fetch_one(&dst) | |
| 153 | + | .await | |
| 154 | + | .unwrap(); | |
| 155 | + | assert_eq!(count, 1, "child occurrence kept its recurrence_parent_id link"); | |
| 156 | + | } |
| @@ -75,6 +75,10 @@ async fn restore_preserves_completed_status_and_is_idempotent() { | |||
| 75 | 75 | daily_notes: vec![], | |
| 76 | 76 | attachments: vec![], | |
| 77 | 77 | sync_accounts: vec![], | |
| 78 | + | saved_views: vec![], | |
| 79 | + | weekly_reviews: vec![], | |
| 80 | + | monthly_goals: vec![], | |
| 81 | + | monthly_reflections: vec![], | |
| 78 | 82 | }; | |
| 79 | 83 | ||
| 80 | 84 | // First restore recreates with status + completed_at + id preserved. | |
| @@ -181,6 +185,10 @@ async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp( | |||
| 181 | 185 | daily_notes: vec![], | |
| 182 | 186 | attachments: vec![], | |
| 183 | 187 | sync_accounts: vec![], | |
| 188 | + | saved_views: vec![], | |
| 189 | + | weekly_reviews: vec![], | |
| 190 | + | monthly_goals: vec![], | |
| 191 | + | monthly_reflections: vec![], | |
| 184 | 192 | }; | |
| 185 | 193 | let r = restore_all(&pool, user_id, &input).await.unwrap(); | |
| 186 | 194 | assert_eq!(r.tasks_restored, 1); | |
| @@ -274,6 +282,10 @@ async fn restore_event_preserves_external_metadata() { | |||
| 274 | 282 | daily_notes: vec![], | |
| 275 | 283 | attachments: vec![], | |
| 276 | 284 | sync_accounts: vec![], | |
| 285 | + | saved_views: vec![], | |
| 286 | + | weekly_reviews: vec![], | |
| 287 | + | monthly_goals: vec![], | |
| 288 | + | monthly_reflections: vec![], | |
| 277 | 289 | }; | |
| 278 | 290 | let r = restore_all(&pool, user_id, &input).await.unwrap(); | |
| 279 | 291 | assert_eq!(r.events_restored, 1); | |
| @@ -385,6 +397,10 @@ async fn restore_round_trips_supplemental_collections() { | |||
| 385 | 397 | daily_notes: vec![note.clone()], | |
| 386 | 398 | attachments: vec![attachment.clone()], | |
| 387 | 399 | sync_accounts: vec![account.clone()], | |
| 400 | + | saved_views: vec![], | |
| 401 | + | weekly_reviews: vec![], | |
| 402 | + | monthly_goals: vec![], | |
| 403 | + | monthly_reflections: vec![], | |
| 388 | 404 | }; | |
| 389 | 405 | // Deleting tasks cascades time_sessions + attachments; deleting projects | |
| 390 | 406 | // cascades milestones; daily_notes and sync_accounts are independent. |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | -- Enforce at most one running timer per user at the schema level. | |
| 2 | + | -- | |
| 3 | + | -- `start_timer` checks "is a session already running?" and inserts inside a | |
| 4 | + | -- DEFERRED transaction whose read takes no write lock, so two concurrent calls | |
| 5 | + | -- could both pass the check and insert (ultra-fuzz Run #27 TOCTOU). The guarding | |
| 6 | + | -- index was non-unique, so nothing stopped the double insert. Make it a partial | |
| 7 | + | -- UNIQUE index instead. | |
| 8 | + | ||
| 9 | + | -- First, resolve any pre-existing duplicates so the unique index can be created: | |
| 10 | + | -- keep the most recently started running session per user, close the rest with a | |
| 11 | + | -- zero-length window (ended_at = started_at). | |
| 12 | + | UPDATE time_sessions | |
| 13 | + | SET ended_at = started_at | |
| 14 | + | WHERE ended_at IS NULL | |
| 15 | + | AND id NOT IN ( | |
| 16 | + | SELECT id FROM ( | |
| 17 | + | SELECT id, | |
| 18 | + | ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY started_at DESC, id DESC) AS rn | |
| 19 | + | FROM time_sessions | |
| 20 | + | WHERE ended_at IS NULL | |
| 21 | + | ) | |
| 22 | + | WHERE rn = 1 | |
| 23 | + | ); | |
| 24 | + | ||
| 25 | + | DROP INDEX IF EXISTS idx_time_sessions_active; | |
| 26 | + | CREATE UNIQUE INDEX idx_time_sessions_active | |
| 27 | + | ON time_sessions(user_id) WHERE ended_at IS NULL; |
| @@ -39,17 +39,22 @@ fn backup_filename(now: chrono::DateTime<Utc>) -> String { | |||
| 39 | 39 | /// time_sessions, milestones, daily_notes, attachments, and sync_accounts). | |
| 40 | 40 | pub(crate) async fn collect_full_export( | |
| 41 | 41 | state: &AppState, | |
| 42 | + | user_id: goingson_core::UserId, | |
| 42 | 43 | ) -> Result<FullExport, goingson_core::CoreError> { | |
| 43 | - | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 44 | - | let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; | |
| 45 | - | let events = state.events.list_all(DESKTOP_USER_ID).await?; | |
| 46 | - | let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?; | |
| 47 | - | let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?; | |
| 48 | - | let time_sessions = state.tasks.list_all_time_sessions(DESKTOP_USER_ID).await?; | |
| 49 | - | let milestones = state.milestones.list_all(DESKTOP_USER_ID).await?; | |
| 50 | - | let daily_notes = state.daily_notes.list_all(DESKTOP_USER_ID).await?; | |
| 51 | - | let attachments = state.attachments.list_all(DESKTOP_USER_ID).await?; | |
| 52 | - | let sync_accounts = state.sync_accounts.list_all(DESKTOP_USER_ID).await?; | |
| 44 | + | let projects = state.projects.list_all(user_id).await?; | |
| 45 | + | let tasks = state.tasks.list_all(user_id).await?; | |
| 46 | + | let events = state.events.list_all(user_id).await?; | |
| 47 | + | let emails = state.emails.list_all(user_id, true).await?; | |
| 48 | + | let contacts = state.contacts.list_all(user_id).await?; | |
| 49 | + | let time_sessions = state.tasks.list_all_time_sessions(user_id).await?; | |
| 50 | + | let milestones = state.milestones.list_all(user_id).await?; | |
| 51 | + | let daily_notes = state.daily_notes.list_all(user_id).await?; | |
| 52 | + | let attachments = state.attachments.list_all(user_id).await?; | |
| 53 | + | let sync_accounts = state.sync_accounts.list_all(user_id).await?; | |
| 54 | + | let saved_views = state.saved_views.list_all(user_id).await?; | |
| 55 | + | let weekly_reviews = state.weekly_reviews.list_all(user_id).await?; | |
| 56 | + | let monthly_goals = state.monthly_reviews.list_all_goals(user_id).await?; | |
| 57 | + | let monthly_reflections = state.monthly_reviews.list_all_reflections(user_id).await?; | |
| 53 | 58 | Ok(FullExport::new( | |
| 54 | 59 | projects, | |
| 55 | 60 | tasks, | |
| @@ -61,6 +66,10 @@ pub(crate) async fn collect_full_export( | |||
| 61 | 66 | daily_notes, | |
| 62 | 67 | attachments, | |
| 63 | 68 | sync_accounts, | |
| 69 | + | saved_views, | |
| 70 | + | weekly_reviews, | |
| 71 | + | monthly_goals, | |
| 72 | + | monthly_reflections, | |
| 64 | 73 | )) | |
| 65 | 74 | } | |
| 66 | 75 | ||
| @@ -155,7 +164,7 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu | |||
| 155 | 164 | let filename = backup_filename(now); | |
| 156 | 165 | let file_path = backup_dir.join(&filename); | |
| 157 | 166 | ||
| 158 | - | let export = collect_full_export(state).await.map_err(|e| e.to_string())?; | |
| 167 | + | let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?; | |
| 159 | 168 | let item_count = export.total_count(); | |
| 160 | 169 | let max_to_keep = settings.max_backups_to_keep as usize; | |
| 161 | 170 | ||
| @@ -253,7 +262,7 @@ pub async fn create_backup_now( | |||
| 253 | 262 | let filename = backup_filename(now); | |
| 254 | 263 | let file_path = backup_dir.join(&filename); | |
| 255 | 264 | ||
| 256 | - | let export = collect_full_export(state).await.map_err(|e| e.to_string())?; | |
| 265 | + | let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?; | |
| 257 | 266 | let item_count = export.total_count(); | |
| 258 | 267 | let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 259 | 268 |
| @@ -139,7 +139,7 @@ pub async fn export_json( | |||
| 139 | 139 | ) -> Result<ExportResponse, ApiError> { | |
| 140 | 140 | validate_export_path(&file_path)?; | |
| 141 | 141 | ||
| 142 | - | let export = crate::backup_scheduler::collect_full_export(&state).await?; | |
| 142 | + | let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID).await?; | |
| 143 | 143 | let item_count = export.total_count(); | |
| 144 | 144 | ||
| 145 | 145 | let size_bytes = backup::write_json(&export, &file_path) | |
| @@ -260,7 +260,7 @@ pub async fn create_backup( | |||
| 260 | 260 | let filename = format!("goingson-backup-{}.json.gz", timestamp); | |
| 261 | 261 | let file_path = backup_dir.join(&filename); | |
| 262 | 262 | ||
| 263 | - | let export = crate::backup_scheduler::collect_full_export(&state).await?; | |
| 263 | + | let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID).await?; | |
| 264 | 264 | let item_count = export.total_count(); | |
| 265 | 265 | ||
| 266 | 266 | let size_bytes = backup::write_backup(&export, &file_path) | |
| @@ -380,6 +380,10 @@ pub async fn restore_backup( | |||
| 380 | 380 | daily_notes: export.daily_notes, | |
| 381 | 381 | attachments: export.attachments, | |
| 382 | 382 | sync_accounts: export.sync_accounts, | |
| 383 | + | saved_views: export.saved_views, | |
| 384 | + | weekly_reviews: export.weekly_reviews, | |
| 385 | + | monthly_goals: export.monthly_goals, | |
| 386 | + | monthly_reflections: export.monthly_reflections, | |
| 383 | 387 | }; | |
| 384 | 388 | ||
| 385 | 389 | // Single all-or-nothing transaction over the shared pool: a mid-restore failure |
| @@ -45,7 +45,7 @@ async fn test_export_json_success() { | |||
| 45 | 45 | assert_eq!(emails.len(), 0); | |
| 46 | 46 | ||
| 47 | 47 | // Write JSON export | |
| 48 | - | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 48 | + | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 49 | 49 | assert_eq!(export.total_count(), 3); // 1 project + 1 task + 1 event | |
| 50 | 50 | ||
| 51 | 51 | let dir = tempdir().unwrap(); | |
| @@ -69,7 +69,7 @@ async fn test_export_json_empty_database() { | |||
| 69 | 69 | let events = state.events.list_all(user_id).await.unwrap(); | |
| 70 | 70 | let emails = state.emails.list_all(user_id, true).await.unwrap(); | |
| 71 | 71 | ||
| 72 | - | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 72 | + | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 73 | 73 | assert_eq!(export.total_count(), 0); | |
| 74 | 74 | ||
| 75 | 75 | let dir = tempdir().unwrap(); | |
| @@ -223,7 +223,7 @@ async fn test_create_backup_success() { | |||
| 223 | 223 | let events = state.events.list_all(user_id).await.unwrap(); | |
| 224 | 224 | let emails = state.emails.list_all(user_id, true).await.unwrap(); | |
| 225 | 225 | ||
| 226 | - | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 226 | + | let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 227 | 227 | assert_eq!(export.total_count(), 3); | |
| 228 | 228 | ||
| 229 | 229 | let dir = tempdir().unwrap(); | |
| @@ -267,6 +267,85 @@ fn test_validate_export_path_accepts_normal_path() { | |||
| 267 | 267 | ||
| 268 | 268 | // ============ Export Summary ============ | |
| 269 | 269 | ||
| 270 | + | // ============ Backup completeness (CHRONIC-D) ============ | |
| 271 | + | ||
| 272 | + | #[tokio::test] | |
| 273 | + | async fn test_collect_full_export_includes_saved_views_and_reviews() { | |
| 274 | + | use chrono::NaiveDate; | |
| 275 | + | use goingson_core::models::{NewSavedView, ViewFilters, ViewType}; | |
| 276 | + | ||
| 277 | + | let (state, user_id) = setup_test_state().await; | |
| 278 | + | ||
| 279 | + | // Seed one row in each previously-omitted collection (ultra-fuzz Run #27 SERIOUS: | |
| 280 | + | // backup dropped saved views, weekly/monthly reviews, vacation days). | |
| 281 | + | state | |
| 282 | + | .saved_views | |
| 283 | + | .create( | |
| 284 | + | user_id, | |
| 285 | + | NewSavedView { | |
| 286 | + | name: "Pinned tasks".into(), | |
| 287 | + | view_type: ViewType::Tasks, | |
| 288 | + | filters: ViewFilters::default(), | |
| 289 | + | sort_by: None, | |
| 290 | + | sort_order: None, | |
| 291 | + | is_pinned: Some(true), | |
| 292 | + | }, | |
| 293 | + | ) | |
| 294 | + | .await | |
| 295 | + | .unwrap(); | |
| 296 | + | ||
| 297 | + | let week_start = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); | |
| 298 | + | state.weekly_reviews.upsert(user_id, week_start, "Good week").await.unwrap(); | |
| 299 | + | state.weekly_reviews.set_vacation_days(user_id, week_start, &[0, 4]).await.unwrap(); | |
| 300 | + | ||
| 301 | + | state.monthly_reviews.upsert_goal(user_id, "2026-06", "Ship v1", 1).await.unwrap(); | |
| 302 | + | state.monthly_reviews.upsert_reflection(user_id, "2026-06", "Highlight", "Change").await.unwrap(); | |
| 303 | + | ||
| 304 | + | // The single backup-gather path must now carry all four. | |
| 305 | + | let export = crate::backup_scheduler::collect_full_export(&state, user_id).await.unwrap(); | |
| 306 | + | assert_eq!(export.saved_views.len(), 1, "saved views gathered"); | |
| 307 | + | assert_eq!(export.weekly_reviews.len(), 1, "weekly reviews gathered"); | |
| 308 | + | assert_eq!(export.weekly_reviews[0].vacation_days, vec![0, 4], "vacation days carried"); | |
| 309 | + | assert_eq!(export.monthly_goals.len(), 1, "monthly goals gathered"); | |
| 310 | + | assert_eq!(export.monthly_reflections.len(), 1, "monthly reflections gathered"); | |
| 311 | + | ||
| 312 | + | // And they survive a write/read serialization round-trip. | |
| 313 | + | let dir = tempdir().unwrap(); | |
| 314 | + | let path = dir.path().join("backup.json.gz"); | |
| 315 | + | backup::write_backup(&export, &path).unwrap(); | |
| 316 | + | let restored = backup::read_backup(&path).unwrap(); | |
| 317 | + | assert_eq!(restored.saved_views.len(), 1); | |
| 318 | + | assert_eq!(restored.weekly_reviews.len(), 1); | |
| 319 | + | assert_eq!(restored.monthly_goals.len(), 1); | |
| 320 | + | assert_eq!(restored.monthly_reflections.len(), 1); | |
| 321 | + | ||
| 322 | + | // And restore writes them into a fresh database (exercises the restore_* fns). | |
| 323 | + | let (dst, dst_user) = setup_test_state().await; | |
| 324 | + | let input = goingson_core::backup_restore::RestoreInput { | |
| 325 | + | projects: restored.projects, | |
| 326 | + | tasks: restored.tasks, | |
| 327 | + | events: restored.events, | |
| 328 | + | emails: restored.emails, | |
| 329 | + | contacts: restored.contacts, | |
| 330 | + | time_sessions: restored.time_sessions, | |
| 331 | + | milestones: restored.milestones, | |
| 332 | + | daily_notes: restored.daily_notes, | |
| 333 | + | attachments: restored.attachments, | |
| 334 | + | sync_accounts: restored.sync_accounts, | |
| 335 | + | saved_views: restored.saved_views, | |
| 336 | + | weekly_reviews: restored.weekly_reviews, | |
| 337 | + | monthly_goals: restored.monthly_goals, | |
| 338 | + | monthly_reflections: restored.monthly_reflections, | |
| 339 | + | }; | |
| 340 | + | let result = goingson_db_sqlite::restore_all(&dst.pool, dst_user, &input).await.unwrap(); | |
| 341 | + | assert_eq!(result.saved_views_restored, 1); | |
| 342 | + | assert_eq!(result.weekly_reviews_restored, 1); | |
| 343 | + | assert_eq!(result.monthly_goals_restored, 1); | |
| 344 | + | assert_eq!(result.monthly_reflections_restored, 1); | |
| 345 | + | assert_eq!(dst.saved_views.list_all(dst_user).await.unwrap().len(), 1); | |
| 346 | + | assert_eq!(dst.weekly_reviews.list_all(dst_user).await.unwrap().len(), 1); | |
| 347 | + | } | |
| 348 | + | ||
| 270 | 349 | #[tokio::test] | |
| 271 | 350 | async fn test_export_summary_counts() { | |
| 272 | 351 | let (state, user_id) = setup_test_state().await; |
| @@ -13,8 +13,8 @@ use flate2::Compression; | |||
| 13 | 13 | use serde::{Deserialize, Serialize}; | |
| 14 | 14 | ||
| 15 | 15 | use goingson_core::{ | |
| 16 | - | Attachment, Contact, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task, | |
| 17 | - | TimeSession, | |
| 16 | + | Attachment, Contact, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, | |
| 17 | + | Project, SavedView, SyncAccount, Task, TimeSession, WeeklyReview, | |
| 18 | 18 | }; | |
| 19 | 19 | ||
| 20 | 20 | /// Full export of all GoingsOn data. | |
| @@ -58,11 +58,23 @@ pub struct FullExport { | |||
| 58 | 58 | /// All calendar/contact sync accounts. | |
| 59 | 59 | #[serde(default)] | |
| 60 | 60 | pub sync_accounts: Vec<SyncAccount>, | |
| 61 | + | /// All saved views / filters. | |
| 62 | + | #[serde(default)] | |
| 63 | + | pub saved_views: Vec<SavedView>, | |
| 64 | + | /// All weekly review rows (notes + vacation days). | |
| 65 | + | #[serde(default)] | |
| 66 | + | pub weekly_reviews: Vec<WeeklyReview>, | |
| 67 | + | /// All monthly goals. | |
| 68 | + | #[serde(default)] | |
| 69 | + | pub monthly_goals: Vec<MonthlyGoal>, | |
| 70 | + | /// All monthly reflections. | |
| 71 | + | #[serde(default)] | |
| 72 | + | pub monthly_reflections: Vec<MonthlyReflection>, | |
| 61 | 73 | } | |
| 62 | 74 | ||
| 63 | 75 | impl FullExport { | |
| 64 | 76 | /// Current export format version. | |
| 65 | - | pub const CURRENT_VERSION: &'static str = "1.2"; | |
| 77 | + | pub const CURRENT_VERSION: &'static str = "1.3"; | |
| 66 | 78 | ||
| 67 | 79 | /// Creates a new full export with the current timestamp. | |
| 68 | 80 | /// | |
| @@ -80,6 +92,10 @@ impl FullExport { | |||
| 80 | 92 | daily_notes: Vec<DailyNote>, | |
| 81 | 93 | attachments: Vec<Attachment>, | |
| 82 | 94 | sync_accounts: Vec<SyncAccount>, | |
| 95 | + | saved_views: Vec<SavedView>, | |
| 96 | + | weekly_reviews: Vec<WeeklyReview>, | |
| 97 | + | monthly_goals: Vec<MonthlyGoal>, | |
| 98 | + | monthly_reflections: Vec<MonthlyReflection>, | |
| 83 | 99 | ) -> Self { | |
| 84 | 100 | Self { | |
| 85 | 101 | version: Self::CURRENT_VERSION.to_string(), | |
| @@ -94,6 +110,10 @@ impl FullExport { | |||
| 94 | 110 | daily_notes, | |
| 95 | 111 | attachments, | |
| 96 | 112 | sync_accounts, | |
| 113 | + | saved_views, | |
| 114 | + | weekly_reviews, | |
| 115 | + | monthly_goals, | |
| 116 | + | monthly_reflections, | |
| 97 | 117 | } | |
| 98 | 118 | } | |
| 99 | 119 | ||
| @@ -109,6 +129,10 @@ impl FullExport { | |||
| 109 | 129 | + self.daily_notes.len() | |
| 110 | 130 | + self.attachments.len() | |
| 111 | 131 | + self.sync_accounts.len() | |
| 132 | + | + self.saved_views.len() | |
| 133 | + | + self.weekly_reviews.len() | |
| 134 | + | + self.monthly_goals.len() | |
| 135 | + | + self.monthly_reflections.len() | |
| 112 | 136 | } | |
| 113 | 137 | ||
| 114 | 138 | /// Checks if this export version is compatible with the current version. | |
| @@ -251,22 +275,31 @@ mod tests { | |||
| 251 | 275 | use super::*; | |
| 252 | 276 | use tempfile::tempdir; | |
| 253 | 277 | ||
| 278 | + | /// An empty export with every collection vec defaulted -- keeps the tests from | |
| 279 | + | /// re-listing the positional collection arguments. | |
| 280 | + | fn empty_export() -> FullExport { | |
| 281 | + | FullExport::new( | |
| 282 | + | vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], | |
| 283 | + | vec![], vec![], vec![], | |
| 284 | + | ) | |
| 285 | + | } | |
| 286 | + | ||
| 254 | 287 | #[test] | |
| 255 | 288 | fn test_full_export_total_count() { | |
| 256 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 289 | + | let export = empty_export(); | |
| 257 | 290 | assert_eq!(export.total_count(), 0); | |
| 258 | 291 | } | |
| 259 | 292 | ||
| 260 | 293 | #[test] | |
| 261 | 294 | fn test_full_export_is_compatible() { | |
| 262 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 295 | + | let export = empty_export(); | |
| 263 | 296 | assert!(export.is_compatible()); | |
| 264 | 297 | ||
| 265 | - | let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 298 | + | let mut old_export = empty_export(); | |
| 266 | 299 | old_export.version = "1.5".to_string(); | |
| 267 | 300 | assert!(old_export.is_compatible()); | |
| 268 | 301 | ||
| 269 | - | let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 302 | + | let mut future_export = empty_export(); | |
| 270 | 303 | future_export.version = "2.0".to_string(); | |
| 271 | 304 | assert!(!future_export.is_compatible()); | |
| 272 | 305 | } | |
| @@ -276,7 +309,7 @@ mod tests { | |||
| 276 | 309 | let dir = tempdir().unwrap(); | |
| 277 | 310 | let backup_path = dir.path().join("test.json.gz"); | |
| 278 | 311 | ||
| 279 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 312 | + | let export = empty_export(); | |
| 280 | 313 | write_backup(&export, &backup_path).unwrap(); | |
| 281 | 314 | ||
| 282 | 315 | let restored = read_backup(&backup_path).unwrap(); | |
| @@ -289,7 +322,7 @@ mod tests { | |||
| 289 | 322 | let dir = tempdir().unwrap(); | |
| 290 | 323 | let json_path = dir.path().join("test.json"); | |
| 291 | 324 | ||
| 292 | - | let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]); | |
| 325 | + | let export = empty_export(); | |
| 293 | 326 | let size = write_json(&export, &json_path).unwrap(); | |
| 294 | 327 | assert!(size > 0); | |
| 295 | 328 |