diff --git a/Cargo.lock b/Cargo.lock index 4b5ab5cb9..210743eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3086,6 +3086,18 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rstest" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros 0.18.2", + "rustc_version", +] + [[package]] name = "rstest" version = "0.24.0" @@ -3094,10 +3106,27 @@ checksum = "03e905296805ab93e13c1ec3a03f4b6c4f35e9498a3d5fa96dc626d22c03cd89" dependencies = [ "futures-timer", "futures-util", - "rstest_macros", + "rstest_macros 0.24.0", "rustc_version", ] +[[package]] +name = "rstest_macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rstest_macros" version = "0.24.0" @@ -5083,7 +5112,7 @@ dependencies = [ "regex", "reqwest", "roxmltree", - "rstest", + "rstest 0.24.0", "rstest_reuse", "rustls", "scraper", @@ -5118,7 +5147,7 @@ dependencies = [ "http 1.3.1", "paste", "reqwest", - "rstest", + "rstest 0.24.0", "rstest_reuse", "serde", "serde_json", @@ -5191,7 +5220,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "rstest", + "rstest 0.24.0", "serde", "serde_json", "strum", @@ -5210,7 +5239,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "rstest", + "rstest 0.24.0", "serde", "strum", "strum_macros", @@ -5230,7 +5259,7 @@ dependencies = [ "fluent-langneg", "fluent-templates", "paste", - "rstest", + "rstest 0.24.0", "strum", "strum_macros", "thiserror 2.0.17", @@ -5248,7 +5277,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "rstest", + "rstest 0.24.0", "serde_json", "syn", "trybuild", @@ -5288,7 +5317,11 @@ dependencies = [ name = "wp_mobile_cache" version = "0.1.0" dependencies = [ + "chrono", + "rstest 0.18.2", "rusqlite", + "serde", + "serde_json", "thiserror 2.0.17", "uniffi", "wp_api", @@ -5309,7 +5342,7 @@ name = "wp_serde_helper" version = "0.1.0" dependencies = [ "chrono", - "rstest", + "rstest 0.24.0", "serde", "serde_json", ] diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt index d70f56ee5..2caf0409c 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/WordPressApiCacheTest.kt @@ -11,7 +11,7 @@ class WordPressApiCacheTest { @Test fun testThatMigrationsWork() = runTest { - assertEquals(2, WordPressApiCache().performMigrations()) + assertEquals(3, WordPressApiCache().performMigrations()) } @Test diff --git a/wp_api/src/taxonomies.rs b/wp_api/src/taxonomies.rs index 303c5b0e4..40f25de2d 100644 --- a/wp_api/src/taxonomies.rs +++ b/wp_api/src/taxonomies.rs @@ -37,6 +37,24 @@ impl Display for TaxonomyType { } } +impl From<&str> for TaxonomyType { + fn from(s: &str) -> Self { + match s { + "category" => Self::Category, + "post_tag" => Self::PostTag, + "nav_menu" => Self::NavMenu, + "wp_pattern_category" => Self::WpPatternCategory, + _ => Self::Custom(s.to_string()), + } + } +} + +impl From for TaxonomyType { + fn from(s: String) -> Self { + Self::from(s.as_str()) + } +} + #[derive( Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, uniffi::Enum, )] diff --git a/wp_mobile_cache/Cargo.toml b/wp_mobile_cache/Cargo.toml index ea547712e..7bcd3e21a 100644 --- a/wp_mobile_cache/Cargo.toml +++ b/wp_mobile_cache/Cargo.toml @@ -8,6 +8,12 @@ default = ["rusqlite/bundled"] [dependencies] rusqlite = { version = "0.37.0", features = ["hooks"] } +serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } uniffi = { workspace = true } wp_api = { path = "../wp_api" } + +[dev-dependencies] +chrono = { workspace = true } +rstest = "0.18" diff --git a/wp_mobile_cache/migrations/0001-create-posts-table.sql b/wp_mobile_cache/migrations/0001-create-posts-table.sql deleted file mode 100644 index 07a74c91c..000000000 --- a/wp_mobile_cache/migrations/0001-create-posts-table.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE `posts` ( - `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, - `post_id` INTEGER NOT NULL, - `context` TEXT COLLATE NOCASE NOT NULL, - `post_author` INTEGER NOT NULL, - `post_date` TEXT COLLATE NOCASE NOT NULL, - `post_content` TEXT COLLATE NOCASE NOT NULL, - `post_title` TEXT COLLATE NOCASE NOT NULL, - `post_excerpt` TEXT COLLATE NOCASE NOT NULL, - `post_status` TEXT COLLATE NOCASE NOT NULL, - `comment_status` TEXT COLLATE NOCASE NOT NULL, - `ping_status` TEXT COLLATE NOCASE NOT NULL, - `post_password` TEXT COLLATE NOCASE DEFAULT NULL, - `post_modified` TEXT COLLATE NOCASE NOT NULL, - `post_parent` INTEGER, - `guid` TEXT COLLATE NOCASE NOT NULL, - `menu_order` INTEGER NOT NULL DEFAULT '0', - `post_type` TEXT COLLATE NOCASE NOT NULL, - `comment_count` INTEGER NOT NULL -) STRICT; - -CREATE UNIQUE INDEX idx_posts_have_unique_post_id_and_context ON posts(post_id, context); diff --git a/wp_mobile_cache/migrations/0001-create-sites-table.sql b/wp_mobile_cache/migrations/0001-create-sites-table.sql new file mode 100644 index 000000000..f1a2dc510 --- /dev/null +++ b/wp_mobile_cache/migrations/0001-create-sites-table.sql @@ -0,0 +1,4 @@ +CREATE TABLE `sites` ( + -- Internal DB field (auto-incrementing) + `id` INTEGER PRIMARY KEY AUTOINCREMENT +) STRICT; diff --git a/wp_mobile_cache/migrations/0002-create-posts-table.sql b/wp_mobile_cache/migrations/0002-create-posts-table.sql new file mode 100644 index 000000000..31c1cc53d --- /dev/null +++ b/wp_mobile_cache/migrations/0002-create-posts-table.sql @@ -0,0 +1,64 @@ +CREATE TABLE `posts_edit_context` ( + -- Internal DB field (auto-incrementing) + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Site identifier (foreign key to sites table) + `db_site_id` INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + + -- Top-level non-nullable fields + `id` INTEGER NOT NULL, + `date` TEXT NOT NULL, + `date_gmt` TEXT NOT NULL, + `link` TEXT NOT NULL, + `modified` TEXT NOT NULL, + `modified_gmt` TEXT NOT NULL, + `slug` TEXT NOT NULL, + `status` TEXT NOT NULL, + `post_type` TEXT NOT NULL, + `password` TEXT NOT NULL, + `template` TEXT NOT NULL, + + -- Top-level optional fields + `permalink_template` TEXT, + `generated_slug` TEXT, + `author` INTEGER, + `featured_media` INTEGER, + `sticky` INTEGER, + `parent` INTEGER, + `menu_order` INTEGER, + + -- Optional enums (stored as TEXT) + `comment_status` TEXT, + `ping_status` TEXT, + `format` TEXT, + + -- Complex optional fields (JSON) + `meta` TEXT, + + -- Nested: guid (guid is non-optional, but guid.raw is optional) + `guid_raw` TEXT, + `guid_rendered` TEXT NOT NULL, + + -- Nested: title (title is non-optional, but title.raw is optional) + `title_raw` TEXT, + `title_rendered` TEXT NOT NULL, + + -- Nested: content (content is non-optional, but some fields are optional) + `content_raw` TEXT, + `content_rendered` TEXT NOT NULL, + `content_protected` INTEGER, + `content_block_version` INTEGER, + + -- Nested: excerpt (entire struct is optional) + `excerpt_raw` TEXT, + `excerpt_rendered` TEXT, + `excerpt_protected` INTEGER, + + -- Client-side cache metadata: when this post was last fetched from the WordPress API + `last_fetched_at` TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + + FOREIGN KEY (db_site_id) REFERENCES sites(id) ON DELETE CASCADE +) STRICT; + +CREATE UNIQUE INDEX idx_posts_edit_context_unique_db_site_id_and_id ON posts_edit_context(db_site_id, id); +CREATE INDEX idx_posts_edit_context_db_site_id ON posts_edit_context(db_site_id); diff --git a/wp_mobile_cache/migrations/0002-create-users-table.sql b/wp_mobile_cache/migrations/0002-create-users-table.sql deleted file mode 100644 index b5b68af95..000000000 --- a/wp_mobile_cache/migrations/0002-create-users-table.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE `users` ( - `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, - `user_id` INTEGER NOT NULL, - `context` TEXT COLLATE NOCASE NOT NULL, - `user_login` TEXT COLLATE NOCASE NOT NULL, - `user_nicename` TEXT COLLATE NOCASE NOT NULL, - `user_email` TEXT COLLATE NOCASE NOT NULL, - `user_url` TEXT COLLATE NOCASE NOT NULL, - `user_registered` TEXT COLLATE NOCASE NOT NULL, - `user_status` INTEGER NOT NULL, - `display_name` TEXT COLLATE NOCASE NOT NULL -) STRICT; - -CREATE UNIQUE INDEX idx_users_have_unique_user_id_and_context ON users(user_id, context); diff --git a/wp_mobile_cache/migrations/0003-create-term-relationships.sql b/wp_mobile_cache/migrations/0003-create-term-relationships.sql new file mode 100644 index 000000000..9c1c3f5a1 --- /dev/null +++ b/wp_mobile_cache/migrations/0003-create-term-relationships.sql @@ -0,0 +1,31 @@ +CREATE TABLE `term_relationships` ( + -- Internal DB field (auto-incrementing) + `rowid` INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Site identifier (foreign key to sites table) + `db_site_id` INTEGER NOT NULL, + + -- Object identifier (rowid of post/page/nav_menu_item/etc) + -- Note: No FK constraint since this can reference different tables + `object_id` INTEGER NOT NULL, + + -- WordPress term ID + `term_id` INTEGER NOT NULL, + + -- Taxonomy type ('category', 'post_tag', or custom taxonomy) + `taxonomy_type` TEXT NOT NULL, + + FOREIGN KEY (db_site_id) REFERENCES sites(id) ON DELETE CASCADE +) STRICT; + +-- Prevent duplicate associations (same object can't have same term twice in same taxonomy) +CREATE UNIQUE INDEX idx_term_relationships_unique + ON term_relationships(db_site_id, object_id, term_id, taxonomy_type); + +-- Query: "Find all objects with taxonomy X and term Y" +CREATE INDEX idx_term_relationships_by_term + ON term_relationships(db_site_id, taxonomy_type, term_id); + +-- Query: "Find all terms for object X" (used in joins when reading posts) +CREATE INDEX idx_term_relationships_by_object + ON term_relationships(db_site_id, object_id); diff --git a/wp_mobile_cache/src/lib.rs b/wp_mobile_cache/src/lib.rs index 0bb2f884b..e7bafd63d 100644 --- a/wp_mobile_cache/src/lib.rs +++ b/wp_mobile_cache/src/lib.rs @@ -1,7 +1,15 @@ use rusqlite::hooks::Action; +use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput}; use rusqlite::{Connection, Result as SqliteResult, params}; use std::sync::{Arc, Mutex}; +pub mod mappings; +pub mod repository; +pub mod term_relationships; + +#[cfg(test)] +pub mod test_fixtures; + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] pub enum SqliteDbError { SqliteError(String), @@ -21,6 +29,88 @@ impl From for SqliteDbError { } } +/// Represents a database row ID (autoincrement field). +/// SQLite rowids are guaranteed to be non-negative, so we use u64. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct RowId(pub u64); + +impl ToSql for RowId { + fn to_sql(&self) -> rusqlite::Result> { + Ok(ToSqlOutput::from(self.0 as i64)) + } +} + +impl FromSql for RowId { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> FromSqlResult { + i64::column_result(value).map(|i| { + debug_assert!(i >= 0, "RowId should be non-negative, got: {}", i); + RowId(i as u64) + }) + } +} + +impl From for RowId { + fn from(value: i64) -> Self { + debug_assert!(value >= 0, "RowId should be non-negative, got: {}", value); + RowId(value as u64) + } +} + +impl RowId { + /// Convert a slice of RowIds to a comma-separated string for use in SQL IN clauses. + /// + /// This helper is used when building dynamic SQL queries with arrays of IDs. + /// Since RowIds are internal database IDs (not user input), this is safe from SQL injection. + /// + /// # Example + /// ```ignore + /// let row_ids = vec![RowId(1), RowId(2), RowId(3)]; + /// let ids_str = RowId::to_sql_list(&row_ids); // "1, 2, 3" + /// let sql = format!("SELECT * FROM table WHERE id IN ({})", ids_str); + /// ``` + pub fn to_sql_list(row_ids: &[RowId]) -> String { + row_ids + .iter() + .map(|id| id.0.to_string()) + .collect::>() + .join(", ") + } +} + +impl From for i64 { + fn from(row_id: RowId) -> Self { + row_id.0 as i64 + } +} + +/// Represents a cached WordPress site in the database. +/// +/// # Design Rationale +/// +/// This is intentionally a database-specific type (hence the `Db` prefix) rather than +/// a domain type representing a WordPress site. This design choice prevents confusion: +/// +/// - **Not a WordPress.com site ID**: The `row_id` has no relationship to WordPress.com site IDs +/// - **Not a self-hosted site identifier**: Self-hosted sites don't have numeric IDs +/// - **Internal cache identifier only**: This ID exists only for our local database's multi-site support +/// +/// # Future Extension +/// +/// When site type tables are added (e.g., `self_hosted_sites`, `wordpress_com_sites`), this +/// struct will gain additional fields: +/// +/// ```ignore +/// pub struct DbSite { +/// pub row_id: RowId, +/// pub site_type: SiteType, // SelfHosted | WordPressCom +/// pub mapped_site_id: RowId, // Foreign key to specific site type table +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct DbSite { + pub row_id: RowId, +} + #[derive(uniffi::Object)] pub struct WpApiCache { inner: DBManager, @@ -62,9 +152,10 @@ impl WpApiCache { } } -static MIGRATION_QUERIES: [&str; 2] = [ - include_str!("../migrations/0001-create-posts-table.sql"), - include_str!("../migrations/0002-create-users-table.sql"), +static MIGRATION_QUERIES: [&str; 3] = [ + include_str!("../migrations/0001-create-sites-table.sql"), + include_str!("../migrations/0002-create-posts-table.sql"), + include_str!("../migrations/0003-create-term-relationships.sql"), ]; pub struct MigrationManager<'a> { diff --git a/wp_mobile_cache/src/mappings.rs b/wp_mobile_cache/src/mappings.rs new file mode 100644 index 000000000..cd5e0f503 --- /dev/null +++ b/wp_mobile_cache/src/mappings.rs @@ -0,0 +1,30 @@ +use rusqlite::Row; + +pub mod helpers; +pub mod posts; +pub mod term_relationships; + +/// Trait for types that can be used as column indexes. +/// Implemented by column enum types to provide type-safe column access. +pub trait ColumnIndex { + fn as_index(&self) -> usize; +} + +/// Extension trait for `Row` that provides convenient column access. +pub trait RowExt { + /// Get a value from a column using a column enum. + fn get_column(&self, column: C) -> rusqlite::Result + where + C: ColumnIndex, + T: rusqlite::types::FromSql; +} + +impl RowExt for Row<'_> { + fn get_column(&self, column: C) -> rusqlite::Result + where + C: ColumnIndex, + T: rusqlite::types::FromSql, + { + self.get(column.as_index()) + } +} diff --git a/wp_mobile_cache/src/mappings/helpers.rs b/wp_mobile_cache/src/mappings/helpers.rs new file mode 100644 index 000000000..690d2ea27 --- /dev/null +++ b/wp_mobile_cache/src/mappings/helpers.rs @@ -0,0 +1,164 @@ +use crate::{ + SqliteDbError, + mappings::{ColumnIndex, RowExt}, +}; +use rusqlite::Row; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// Helper to get a required ID wrapper type (e.g., PostId, UserId) from a row. +pub fn get_id(row: &Row, column: C) -> Result +where + T: From, + C: ColumnIndex, +{ + let id: i64 = row.get_column(column)?; + Ok(id.into()) +} + +/// Helper to get an optional ID wrapper type from a row. +pub fn get_optional_id(row: &Row, column: C) -> Result, SqliteDbError> +where + T: From, + C: ColumnIndex, +{ + let id: Option = row.get_column(column)?; + Ok(id.map(Into::into)) +} + +/// Helper to parse a required enum from a string column. +pub fn parse_enum(row: &Row, column: C) -> Result +where + T: FromStr, + T::Err: std::fmt::Display, + C: ColumnIndex, +{ + let value_str: String = row.get_column(column)?; + value_str + .parse() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse enum: {}", e))) +} + +/// Helper to parse an optional enum from a string column. +pub fn parse_optional_enum(row: &Row, column: C) -> Result, SqliteDbError> +where + T: FromStr, + T::Err: std::fmt::Display, + C: ColumnIndex, +{ + let value_str: Option = row.get_column(column)?; + value_str + .map(|s| s.parse()) + .transpose() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse enum: {}", e))) +} + +/// Helper to deserialize a JSON array from a TEXT column. +pub fn deserialize_json_array(json_str: Option) -> Result>, SqliteDbError> +where + T: for<'de> Deserialize<'de>, +{ + match json_str { + Some(s) => { + let items: Vec = serde_json::from_str(&s) + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse JSON: {}", e)))?; + Ok(Some(items)) + } + None => Ok(None), + } +} + +/// Helper to serialize a vector to a JSON string. +pub fn serialize_to_json(items: &Option>) -> Result, SqliteDbError> +where + T: Serialize, +{ + items + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to serialize to JSON: {}", e))) +} + +/// Helper to serialize a value to a JSON string. +pub fn serialize_value_to_json(value: &Option) -> Result, SqliteDbError> +where + T: Serialize, +{ + value + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to serialize to JSON: {}", e))) +} + +/// Helper to deserialize a JSON object from a TEXT column. +pub fn deserialize_json_value(json_str: Option) -> Result, SqliteDbError> +where + T: for<'de> Deserialize<'de>, +{ + json_str + .map(|s| serde_json::from_str(&s)) + .transpose() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse JSON: {}", e))) +} + +/// Helper to convert an INTEGER to a boolean (0 = false, non-zero = true). +pub fn integer_to_bool(value: Option) -> Option { + value.map(|v| v != 0) +} + +/// Helper to convert a boolean to an INTEGER (false = 0, true = 1). +pub fn bool_to_integer(value: Option) -> Option { + value.map(|b| if b { 1 } else { 0 }) +} + +/// Helper to parse a required DateTime-like type from a string column. +pub fn parse_datetime(row: &Row, column: C) -> Result +where + T: FromStr, + T::Err: std::fmt::Display, + C: ColumnIndex, +{ + let datetime_str: String = row.get_column(column)?; + datetime_str + .parse() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse datetime: {}", e))) +} + +/// Helper to deserialize a JSON array of ID wrapper types. +/// This handles the case where we store `Vec` as `Vec` in JSON. +pub fn deserialize_json_id_array( + json_str: Option, +) -> Result>, SqliteDbError> +where + T: From, +{ + match json_str { + Some(s) => { + let ids: Vec = serde_json::from_str(&s) + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to parse JSON: {}", e)))?; + Ok(Some(ids.into_iter().map(Into::into).collect())) + } + None => Ok(None), + } +} + +/// Helper to serialize a vector of ID wrapper types to JSON. +/// This handles the case where we store `Vec` as `Vec` in JSON. +pub fn serialize_json_id_array( + items: &Option>, + to_i64: F, +) -> Result, SqliteDbError> +where + F: Fn(&T) -> i64, +{ + items + .as_ref() + .map(|items| { + let ids: Vec = items.iter().map(to_i64).collect(); + serde_json::to_string(&ids) + }) + .transpose() + .map_err(|e| SqliteDbError::SqliteError(format!("Failed to serialize to JSON: {}", e))) +} diff --git a/wp_mobile_cache/src/mappings/posts.rs b/wp_mobile_cache/src/mappings/posts.rs new file mode 100644 index 000000000..c3fd8c780 --- /dev/null +++ b/wp_mobile_cache/src/mappings/posts.rs @@ -0,0 +1,257 @@ +use crate::{ + DbSite, RowId, SqliteDbError, + mappings::{ + ColumnIndex, RowExt, + helpers::{ + deserialize_json_value, get_id, get_optional_id, integer_to_bool, parse_datetime, + parse_enum, parse_optional_enum, + }, + }, + term_relationships::DbTermRelationship, +}; +use rusqlite::Row; +use wp_api::{ + posts::{ + AnyPostWithEditContext, PostContentWithEditContext, PostGuidWithEditContext, + PostTitleWithEditContext, SparsePostExcerpt, + }, + taxonomies::TaxonomyType, +}; + +/// Column indexes for posts_edit_context table. +/// These must match the order of columns in the CREATE TABLE statement. +#[repr(usize)] +#[derive(Debug, Clone, Copy)] +enum PostEditContextColumn { + Rowid = 0, + SiteId = 1, + Id = 2, + Date = 3, + DateGmt = 4, + Link = 5, + Modified = 6, + ModifiedGmt = 7, + Slug = 8, + Status = 9, + PostType = 10, + Password = 11, + Template = 12, + PermalinkTemplate = 13, + GeneratedSlug = 14, + Author = 15, + FeaturedMedia = 16, + Sticky = 17, + Parent = 18, + MenuOrder = 19, + CommentStatus = 20, + PingStatus = 21, + Format = 22, + Meta = 23, + GuidRaw = 24, + GuidRendered = 25, + TitleRaw = 26, + TitleRendered = 27, + ContentRaw = 28, + ContentRendered = 29, + ContentProtected = 30, + ContentBlockVersion = 31, + ExcerptRaw = 32, + ExcerptRendered = 33, + ExcerptProtected = 34, + LastFetchedAt = 35, +} + +impl ColumnIndex for PostEditContextColumn { + fn as_index(&self) -> usize { + *self as usize + } +} + +pub struct DbAnyPostWithEditContext { + pub row_id: RowId, + pub site: DbSite, + pub post: AnyPostWithEditContext, + pub last_fetched_at: String, +} + +impl DbAnyPostWithEditContext { + /// Construct a post entity from a database row with its associated term relationships. + /// + /// This is the only way to construct a `DbAnyPostWithEditContext`, ensuring that + /// terms are always properly loaded from the term_relationships table. + /// + /// Domain-specific logic for extracting categories and tags from the generic + /// term relationships is handled here in the mapping layer. + /// + /// # Arguments + /// * `row` - Database row containing post data + /// * `term_relationships` - Term relationships loaded from term_relationships table + pub fn from_row_with_terms( + row: &Row, + term_relationships: Vec, + ) -> Result { + use PostEditContextColumn::*; + + let row_id: RowId = row.get_column(Rowid)?; + let site = DbSite { + row_id: row.get_column(PostEditContextColumn::SiteId)?, + }; + + // Extract categories and tags from term relationships + let (categories, tags) = term_relationships.into_iter().fold( + (Vec::new(), Vec::new()), + |(mut cats, mut tags), relationship| { + match relationship.taxonomy_type { + TaxonomyType::Category => cats.push(relationship.term_id), + TaxonomyType::PostTag => tags.push(relationship.term_id), + _ => {} // Ignore other taxonomy types for posts + } + (cats, tags) + }, + ); + + let post = AnyPostWithEditContext { + id: get_id(row, Id)?, + date: row.get_column(Date)?, + date_gmt: parse_datetime(row, DateGmt)?, + guid: PostGuidWithEditContext { + raw: row.get_column(GuidRaw)?, + rendered: row.get_column(GuidRendered)?, + }, + link: row.get_column(Link)?, + modified: row.get_column(Modified)?, + modified_gmt: parse_datetime(row, ModifiedGmt)?, + slug: row.get_column(Slug)?, + status: parse_enum(row, Status)?, + post_type: row.get_column(PostType)?, + password: row.get_column(Password)?, + permalink_template: row.get_column(PermalinkTemplate)?, + generated_slug: row.get_column(GeneratedSlug)?, + title: PostTitleWithEditContext { + raw: row.get_column(TitleRaw)?, + rendered: row.get_column(TitleRendered)?, + }, + content: PostContentWithEditContext { + raw: row.get_column(ContentRaw)?, + rendered: row.get_column(ContentRendered)?, + protected: row.get_column(ContentProtected)?, + block_version: row.get_column(ContentBlockVersion)?, + }, + author: get_optional_id(row, Author)?, + excerpt: { + // Presence of excerpt is determined by excerpt_rendered being Some + let excerpt_rendered: Option = row.get_column(ExcerptRendered)?; + if excerpt_rendered.is_some() { + Some(SparsePostExcerpt { + raw: row.get_column(ExcerptRaw)?, + rendered: excerpt_rendered, + protected: row.get_column(ExcerptProtected)?, + }) + } else { + None + } + }, + featured_media: get_optional_id(row, FeaturedMedia)?, + comment_status: parse_optional_enum(row, CommentStatus)?, + ping_status: parse_optional_enum(row, PingStatus)?, + format: parse_optional_enum(row, Format)?, + meta: deserialize_json_value(row.get_column(Meta)?)?, + sticky: integer_to_bool(row.get_column(Sticky)?), + template: row.get_column(Template)?, + categories: if categories.is_empty() { + None + } else { + Some(categories) + }, + tags: if tags.is_empty() { None } else { Some(tags) }, + parent: get_optional_id(row, Parent)?, + menu_order: row.get_column(MenuOrder)?, + }; + + Ok(Self { + row_id, + site, + post, + last_fetched_at: row.get_column(LastFetchedAt)?, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::test_fixtures::{ + TestContext, assert_recent_timestamp, posts::PostBuilder, test_ctx, + }; + use rstest::*; + use wp_api::posts::{AnyPostWithEditContext, PostStatus}; + + #[rstest] + #[case(PostBuilder::minimal().build())] + #[case(PostBuilder::full().build())] + fn test_round_trip(mut test_ctx: TestContext, #[case] original_post: AnyPostWithEditContext) { + // Insert into database using repository + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &original_post) + .expect("Failed to insert post"); + + // Read back from database using PostRepository's select_by_rowid + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .expect("Failed to read post"); + + // Verify round-trip + assert_eq!(retrieved.row_id, rowid); + assert_eq!(retrieved.site, test_ctx.site); + assert_recent_timestamp(&retrieved.last_fetched_at); + assert_eq!(retrieved.post, original_post); + } + + #[rstest] + #[case(PostStatus::Publish)] + #[case(PostStatus::Draft)] + #[case(PostStatus::Pending)] + #[case(PostStatus::Private)] + #[case(PostStatus::Future)] + #[case(PostStatus::Custom("custom-status".to_string()))] + fn test_round_trip_with_different_enum_variants( + mut test_ctx: TestContext, + #[case] post_status: PostStatus, + ) { + let post = PostBuilder::minimal() + .with_status(post_status.clone()) + .build(); + + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .unwrap(); + + assert_eq!(retrieved.post.status, post_status); + } + + #[rstest] + fn test_round_trip_with_empty_json_arrays(mut test_ctx: TestContext) { + let post = PostBuilder::minimal() + .with_categories(vec![]) + .with_tags(vec![]) + .build(); + + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .unwrap(); + + assert_eq!(retrieved.post.categories, None); + assert_eq!(retrieved.post.tags, None); + } +} diff --git a/wp_mobile_cache/src/mappings/term_relationships.rs b/wp_mobile_cache/src/mappings/term_relationships.rs new file mode 100644 index 000000000..22b390ea2 --- /dev/null +++ b/wp_mobile_cache/src/mappings/term_relationships.rs @@ -0,0 +1,42 @@ +use crate::{ + DbSite, SqliteDbError, + mappings::{ColumnIndex, RowExt}, + term_relationships::DbTermRelationship, +}; +use rusqlite::Row; +use wp_api::terms::TermId; + +/// Column indexes for term_relationships table. +/// These must match the order of columns in the CREATE TABLE statement. +#[repr(usize)] +#[derive(Debug, Clone, Copy)] +enum TermRelationshipColumn { + Rowid = 0, + DbSiteId = 1, + ObjectId = 2, + TermId = 3, + TaxonomyType = 4, +} + +impl ColumnIndex for TermRelationshipColumn { + fn as_index(&self) -> usize { + *self as usize + } +} + +impl DbTermRelationship { + /// Construct a term relationship entity from a database row. + pub fn from_row(row: &Row) -> Result { + use TermRelationshipColumn as Col; + + Ok(Self { + row_id: row.get_column(Col::Rowid)?, + site: DbSite { + row_id: row.get_column(Col::DbSiteId)?, + }, + object_id: row.get_column(Col::ObjectId)?, + term_id: TermId(row.get_column(Col::TermId)?), + taxonomy_type: row.get_column::(Col::TaxonomyType)?.into(), + }) + } +} diff --git a/wp_mobile_cache/src/repository/mod.rs b/wp_mobile_cache/src/repository/mod.rs new file mode 100644 index 000000000..0328a741a --- /dev/null +++ b/wp_mobile_cache/src/repository/mod.rs @@ -0,0 +1,102 @@ +use crate::{RowId, SqliteDbError}; +use rusqlite::Connection; + +pub mod posts; +pub mod term_relationships; + +#[cfg(test)] +mod posts_constraint_tests; +#[cfg(test)] +mod posts_multi_site_tests; +#[cfg(test)] +mod posts_transaction_tests; +#[cfg(test)] +mod term_relationships_multi_site_tests; + +/// Abstraction over database query execution. +/// +/// This trait decouples the repository layer from specific database implementations, +/// making it possible to use different executors (Connection, Transaction, etc.). +pub trait QueryExecutor { + /// Prepare a SQL statement for execution. + fn prepare(&self, sql: &str) -> Result, SqliteDbError>; + + /// Execute a SQL statement with parameters and return the number of affected rows. + fn execute(&self, sql: &str, params: impl rusqlite::Params) -> Result; + + /// Get the rowid of the last inserted row. + fn last_insert_rowid(&self) -> RowId; +} + +/// Trait for types that can manage database transactions. +/// +/// This is separate from QueryExecutor because not all query executors can create transactions +/// (e.g., a Transaction itself cannot create nested transactions in our design). +pub trait TransactionManager: QueryExecutor { + /// Begin a database transaction (requires mutable access). + fn transaction(&mut self) -> Result, SqliteDbError>; +} + +impl QueryExecutor for Connection { + fn prepare(&self, sql: &str) -> Result, SqliteDbError> { + self.prepare(sql).map_err(SqliteDbError::from) + } + + fn execute(&self, sql: &str, params: impl rusqlite::Params) -> Result { + self.execute(sql, params).map_err(SqliteDbError::from) + } + + fn last_insert_rowid(&self) -> RowId { + self.last_insert_rowid().into() + } +} + +impl TransactionManager for Connection { + fn transaction(&mut self) -> Result, SqliteDbError> { + rusqlite::Connection::transaction(self).map_err(SqliteDbError::from) + } +} + +impl<'conn> QueryExecutor for rusqlite::Transaction<'conn> { + fn prepare(&self, sql: &str) -> Result, SqliteDbError> { + rusqlite::Connection::prepare(self, sql).map_err(Into::into) + } + + fn execute(&self, sql: &str, params: impl rusqlite::Params) -> Result { + rusqlite::Connection::execute(self, sql, params).map_err(Into::into) + } + + fn last_insert_rowid(&self) -> RowId { + rusqlite::Connection::last_insert_rowid(self).into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_query_executor_for_connection() { + let conn = Connection::open_in_memory().unwrap(); + QueryExecutor::execute( + &conn, + "CREATE TABLE test_table (id INTEGER PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + + // Test prepare + let stmt = QueryExecutor::prepare(&conn, "SELECT * FROM test_table").unwrap(); + assert!(stmt.column_count() > 0); + + // Test execute + let affected = + QueryExecutor::execute(&conn, "INSERT INTO test_table (value) VALUES (?)", ["test"]) + .unwrap(); + assert_eq!(affected, 1); + + // Test last_insert_rowid + let rowid = QueryExecutor::last_insert_rowid(&conn); + assert_eq!(rowid, RowId(1)); + } +} diff --git a/wp_mobile_cache/src/repository/posts.rs b/wp_mobile_cache/src/repository/posts.rs new file mode 100644 index 000000000..e2ff49da6 --- /dev/null +++ b/wp_mobile_cache/src/repository/posts.rs @@ -0,0 +1,881 @@ +use crate::{ + DbSite, RowId, SqliteDbError, + mappings::{ + helpers::{bool_to_integer, serialize_value_to_json}, + posts::DbAnyPostWithEditContext, + }, + repository::{ + QueryExecutor, TransactionManager, term_relationships::TermRelationshipRepository, + }, +}; +use wp_api::{ + posts::{AnyPostWithEditContext, PostId}, + taxonomies::TaxonomyType, +}; + +/// Repository for managing posts in the database. +/// +/// Provides CRUD operations and post-specific query methods. +pub struct PostRepository; + +impl PostRepository { + const TABLE_NAME: &'static str = "posts_edit_context"; + + /// Select a post by its SQLite rowid for a given site (returns wrapper with rowid). + /// + /// Returns an error if no post with the given rowid exists for this site. + /// Automatically populates categories and tags from term_relationships table. + pub fn select_by_rowid( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + rowid: RowId, + ) -> Result { + // First get the post.id (WordPress ID) from the rowid + let sql = format!( + "SELECT id FROM {} WHERE db_site_id = ? AND rowid = ?", + Self::TABLE_NAME + ); + let mut stmt = executor.prepare(&sql)?; + let post_id: i64 = stmt + .query_row([site.row_id, rowid], |row| row.get(0)) + .map_err(SqliteDbError::from)?; + + // Load term relationships using the WordPress post ID + let term_repo = TermRelationshipRepository; + let terms_map = term_repo.get_terms_for_objects(executor, site, &[post_id])?; + let term_relationships = terms_map.get(&post_id).cloned().unwrap_or_default(); + + // Query and construct post with term relationships + let sql = format!( + "SELECT * FROM {} WHERE db_site_id = ? AND rowid = ?", + Self::TABLE_NAME + ); + let mut stmt = executor.prepare(&sql)?; + stmt.query_row([site.row_id, rowid], |row| { + DbAnyPostWithEditContext::from_row_with_terms(row, term_relationships.clone()) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + }) + .map_err(SqliteDbError::from) + } + + /// Select all posts for a given site (returns wrappers with rowids). + /// + /// Returns an empty vector if no posts exist for the site. + /// Automatically populates categories and tags from term_relationships table. + pub fn select_all( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + ) -> Result, SqliteDbError> { + // First pass: extract post IDs (WordPress IDs, not SQLite rowids) + let sql = format!("SELECT id FROM {} WHERE db_site_id = ?", Self::TABLE_NAME); + let mut stmt = executor.prepare(&sql)?; + let post_ids: Vec = stmt + .query_map([site.row_id], |row| row.get(0))? + .collect::, _>>() + .map_err(SqliteDbError::from)?; + + if post_ids.is_empty() { + return Ok(Vec::new()); + } + + // Batch load term relationships for all posts using WordPress post IDs + let term_repo = TermRelationshipRepository; + let terms_map = term_repo.get_terms_for_objects(executor, site, &post_ids)?; + + // Second pass: construct posts with term relationships + let sql = format!("SELECT * FROM {} WHERE db_site_id = ?", Self::TABLE_NAME); + let mut stmt = executor.prepare(&sql)?; + let posts = stmt + .query_map([site.row_id], |row| { + let post_id: i64 = row.get("id")?; + let term_relationships = terms_map.get(&post_id).cloned().unwrap_or_default(); + DbAnyPostWithEditContext::from_row_with_terms(row, term_relationships) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + })? + .collect::, _>>() + .map_err(SqliteDbError::from)?; + + Ok(posts) + } + + /// Select a post by its WordPress post ID for a given site (returns wrapper with rowid). + /// + /// This is different from `select_by_rowid` which uses the SQLite rowid. + /// The post_id is the WordPress post ID from the REST API. + /// + /// Returns an error if no post with the given WordPress post ID exists for this site. + /// Automatically populates categories and tags from term_relationships table. + pub fn select_by_post_id( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + post_id: PostId, + ) -> Result { + // Load term relationships using the WordPress post ID + let term_repo = TermRelationshipRepository; + let terms_map = term_repo.get_terms_for_objects(executor, site, &[post_id.0])?; + let term_relationships = terms_map.get(&post_id.0).cloned().unwrap_or_default(); + + // Query and construct post with term relationships + let sql = format!( + "SELECT * FROM {} WHERE db_site_id = ? AND id = ?", + Self::TABLE_NAME + ); + let mut stmt = executor.prepare(&sql)?; + stmt.query_row(rusqlite::params![site.row_id, post_id.0], |row| { + DbAnyPostWithEditContext::from_row_with_terms(row, term_relationships.clone()) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + }) + .map_err(SqliteDbError::from) + } + + /// Delete a post by its WordPress post ID for a given site. + /// + /// Returns the number of rows deleted (0 or 1). + /// Automatically deletes associated term relationships. + pub fn delete_by_post_id( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + post_id: PostId, + ) -> Result { + // First, try to get the rowid (if post doesn't exist, return 0) + let db_post = match self.select_by_post_id(executor, site, post_id) { + Ok(post) => post, + Err(_) => return Ok(0), // Post doesn't exist + }; + + // Delete term relationships using WordPress post ID + let term_repo = TermRelationshipRepository; + term_repo.delete_all_terms_for_object(executor, site, db_post.post.id.0)?; + + // Delete the post + let sql = format!( + "DELETE FROM {} WHERE db_site_id = ? AND id = ?", + Self::TABLE_NAME + ); + executor.execute(&sql, rusqlite::params![site.row_id, post_id.0]) + } + + /// Upsert a post with its term relationships (atomic transaction). + /// + /// This uses SQLite's INSERT ... ON CONFLICT ... DO UPDATE syntax to either + /// insert a new post or update an existing one based on the (db_site_id, post_id) pair. + /// This ensures the database observer sees a single INSERT or UPDATE action. + /// + /// Term relationships are synced using a diff approach - only changes generate DB events. + /// + /// Returns the rowid of the inserted or updated row. + pub fn upsert( + &self, + transaction_manager: &mut impl TransactionManager, + site: &DbSite, + post: &AnyPostWithEditContext, + ) -> Result { + let tx = transaction_manager.transaction()?; + + let upsert_sql = format!( + r#" + INSERT INTO {} ( + db_site_id, id, date, date_gmt, link, modified, modified_gmt, slug, status, post_type, + password, template, permalink_template, generated_slug, author, featured_media, + sticky, parent, menu_order, comment_status, ping_status, format, meta, + guid_raw, guid_rendered, title_raw, title_rendered, + content_raw, content_rendered, content_protected, content_block_version, + excerpt_raw, excerpt_rendered, excerpt_protected + ) VALUES ( + :db_site_id, :id, :date, :date_gmt, :link, :modified, :modified_gmt, :slug, :status, :post_type, + :password, :template, :permalink_template, :generated_slug, :author, :featured_media, + :sticky, :parent, :menu_order, :comment_status, :ping_status, :format, :meta, + :guid_raw, :guid_rendered, :title_raw, :title_rendered, + :content_raw, :content_rendered, :content_protected, :content_block_version, + :excerpt_raw, :excerpt_rendered, :excerpt_protected + ) + ON CONFLICT(db_site_id, id) DO UPDATE SET + date = excluded.date, + date_gmt = excluded.date_gmt, + link = excluded.link, + modified = excluded.modified, + modified_gmt = excluded.modified_gmt, + slug = excluded.slug, + status = excluded.status, + post_type = excluded.post_type, + password = excluded.password, + template = excluded.template, + permalink_template = excluded.permalink_template, + generated_slug = excluded.generated_slug, + author = excluded.author, + featured_media = excluded.featured_media, + sticky = excluded.sticky, + parent = excluded.parent, + menu_order = excluded.menu_order, + comment_status = excluded.comment_status, + ping_status = excluded.ping_status, + format = excluded.format, + meta = excluded.meta, + guid_raw = excluded.guid_raw, + guid_rendered = excluded.guid_rendered, + title_raw = excluded.title_raw, + title_rendered = excluded.title_rendered, + content_raw = excluded.content_raw, + content_rendered = excluded.content_rendered, + content_protected = excluded.content_protected, + content_block_version = excluded.content_block_version, + excerpt_raw = excluded.excerpt_raw, + excerpt_rendered = excluded.excerpt_rendered, + excerpt_protected = excluded.excerpt_protected, + last_fetched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + "#, + Self::TABLE_NAME + ); + + tx.execute( + &upsert_sql, + rusqlite::named_params! { + ":db_site_id": site.row_id, + ":id": post.id.0, + ":date": post.date, + ":date_gmt": post.date_gmt.to_string(), + ":link": post.link, + ":modified": post.modified, + ":modified_gmt": post.modified_gmt.to_string(), + ":slug": post.slug, + ":status": post.status.to_string(), + ":post_type": post.post_type, + ":password": post.password, + ":template": post.template, + ":permalink_template": post.permalink_template, + ":generated_slug": post.generated_slug, + ":author": post.author.map(|u| u.0), + ":featured_media": post.featured_media.map(|m| m.0), + ":sticky": bool_to_integer(post.sticky), + ":parent": post.parent.map(|p| p.0), + ":menu_order": post.menu_order, + ":comment_status": post.comment_status.as_ref().map(|s| s.to_string()), + ":ping_status": post.ping_status.as_ref().map(|s| s.to_string()), + ":format": post.format.as_ref().map(|f| f.to_string()), + ":meta": serialize_value_to_json(&post.meta)?, + ":guid_raw": post.guid.raw, + ":guid_rendered": post.guid.rendered, + ":title_raw": post.title.raw, + ":title_rendered": post.title.rendered, + ":content_raw": post.content.raw, + ":content_rendered": post.content.rendered, + ":content_protected": post.content.protected, + ":content_block_version": post.content.block_version, + ":excerpt_raw": post.excerpt.as_ref().and_then(|e| e.raw.clone()), + ":excerpt_rendered": post.excerpt.as_ref().and_then(|e| e.rendered.clone()), + ":excerpt_protected": post.excerpt.as_ref().and_then(|e| e.protected), + }, + )?; + + let sql = format!( + "SELECT rowid FROM {} WHERE db_site_id = ? AND id = ?", + Self::TABLE_NAME + ); + let post_rowid: i64 = { + let mut stmt = tx.prepare(&sql)?; + stmt.query_row(rusqlite::params![site.row_id, post.id.0], |row| row.get(0)) + .map_err(SqliteDbError::from)? + }; + let post_rowid = RowId(post_rowid as u64); + + // Sync term relationships using WordPress post ID, not SQLite rowid + let term_repo = TermRelationshipRepository; + + if let Some(ref categories) = post.categories { + term_repo.sync_terms_for_object( + &tx, + site, + post.id.0, + &TaxonomyType::Category, + categories, + )?; + } + + if let Some(ref tags) = post.tags { + term_repo.sync_terms_for_object(&tx, site, post.id.0, &TaxonomyType::PostTag, tags)?; + } + + tx.commit().map_err(SqliteDbError::from)?; + Ok(post_rowid) + } + + /// Upsert multiple posts with their term relationships. + /// + /// Each post is upserted in its own transaction. If any upsert fails, + /// previously successful upserts remain in the database. + /// + /// Returns a vector of rowids for successfully upserted posts. + pub fn upsert_batch( + &self, + transaction_manager: &mut impl TransactionManager, + site: &DbSite, + posts: &[AnyPostWithEditContext], + ) -> Result, SqliteDbError> { + posts + .iter() + .map(|post| self.upsert(transaction_manager, site, post)) + .collect() + } + + /// Get the total count of posts for a given site. + pub fn count( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + ) -> Result { + let sql = format!( + "SELECT COUNT(*) FROM {} WHERE db_site_id = ?", + Self::TABLE_NAME + ); + let mut stmt = executor.prepare(&sql)?; + stmt.query_row([site.row_id], |row| row.get(0)) + .map_err(SqliteDbError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_fixtures::{ + TestContext, assert_recent_timestamp, posts::PostBuilder, test_ctx, + }; + use rstest::*; + use wp_api::posts::PostStatus; + + #[rstest] + fn test_repository_insert_and_select_by_rowid(mut test_ctx: TestContext) { + let post = PostBuilder::minimal().build(); + + // Insert using repository + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .expect("Failed to insert"); + + // Select by rowid + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .expect("Failed to select"); + + assert_eq!(retrieved.row_id, rowid); + assert_eq!(retrieved.site, test_ctx.site); + assert_eq!(retrieved.post, post); + } + + #[rstest] + fn test_repository_select_by_post_id(mut test_ctx: TestContext) { + let post = PostBuilder::minimal().with_id(42).build(); + + // Insert + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .expect("Failed to insert"); + + // Select by post_id + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(42)) + .expect("Failed to select by post_id"); + + assert_eq!(retrieved.post.id, PostId(42)); + assert_eq!(retrieved.site, test_ctx.site); + assert_eq!(retrieved.post, post); + } + + #[rstest] + fn test_repository_select_by_post_id_not_found(test_ctx: TestContext) { + // Try to select non-existent post + let result = + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(999)); + + assert!(result.is_err()); + } + + #[rstest] + fn test_repository_select_all(mut test_ctx: TestContext) { + // Initially empty + let all = test_ctx + .post_repo + .select_all(&test_ctx.conn, &test_ctx.site) + .unwrap(); + assert_eq!(all.len(), 0); + + // Insert posts + let post1 = PostBuilder::minimal().build(); + let post2 = PostBuilder::minimal().build(); + + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post1) + .unwrap(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post2) + .unwrap(); + + // Select all + let all = test_ctx + .post_repo + .select_all(&test_ctx.conn, &test_ctx.site) + .unwrap(); + assert_eq!(all.len(), 2); + } + + #[rstest] + fn test_repository_count(mut test_ctx: TestContext) { + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 0 + ); + + let post1 = PostBuilder::minimal().build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post1) + .unwrap(); + + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 1 + ); + + let post2 = PostBuilder::minimal().build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post2) + .unwrap(); + + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 2 + ); + } + + #[rstest] + fn test_repository_insert_batch(mut test_ctx: TestContext) { + let post1 = PostBuilder::minimal().build(); + let post2 = PostBuilder::full().build(); + let post3 = PostBuilder::minimal().build(); + + let posts = vec![post1, post2, post3]; + + // Insert batch + let rowids = test_ctx + .post_repo + .upsert_batch(&mut test_ctx.conn, &test_ctx.site, &posts) + .unwrap(); + assert_eq!(rowids.len(), 3); + + // Verify all were inserted + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 3 + ); + + // Verify can retrieve each + rowids.iter().for_each(|&rowid| { + test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .expect("Should exist"); + }); + } + + #[rstest] + fn test_repository_delete_by_post_id(mut test_ctx: TestContext) { + let post = PostBuilder::minimal().with_id(42).build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Verify exists + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(42)) + .expect("Post should exist"); + + // Delete + let deleted = test_ctx + .post_repo + .delete_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(42)) + .unwrap(); + assert_eq!(deleted, 1); + + // Verify no longer exists + let result = + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(42)); + assert!(result.is_err()); + + // Delete non-existent should return 0 + let deleted = test_ctx + .post_repo + .delete_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(999)) + .unwrap(); + assert_eq!(deleted, 0); + } + + #[rstest] + fn test_repository_upsert_inserts_new_post(mut test_ctx: TestContext) { + let post = PostBuilder::minimal() + .with_id(100) + .with_status(PostStatus::Draft) + .build(); + + // Verify post doesn't exist + assert!( + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(100)) + .is_err() + ); + + // Upsert should insert + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Verify it was inserted + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(100)) + .unwrap(); + assert_eq!(retrieved.row_id, rowid); + assert_eq!(retrieved.site, test_ctx.site); + assert_eq!(retrieved.post.status, PostStatus::Draft); + } + + #[rstest] + fn test_repository_upsert_updates_existing_post(mut test_ctx: TestContext) { + // Insert initial post + let post = PostBuilder::minimal() + .with_id(200) + .with_status(PostStatus::Draft) + .with_slug("original-slug") + .build(); + + let original_rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Upsert with updated data + let updated_post = PostBuilder::minimal() + .with_id(200) + .with_status(PostStatus::Publish) + .with_slug("updated-slug") + .build(); + + let new_rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &updated_post) + .unwrap(); + + // Rowid should be the same (it's an update, not delete+insert) + assert_eq!(original_rowid, new_rowid); + + // Verify the update + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(200)) + .unwrap(); + assert_eq!(retrieved.post.status, PostStatus::Publish); + assert_eq!(retrieved.post.slug, "updated-slug"); + + // Verify only one post exists with this ID + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 1 + ); + } + + #[rstest] + fn test_upsert_inserts_post_and_terms(mut test_ctx: TestContext) { + let post = PostBuilder::minimal() + .with_id(300) + .with_categories(vec![wp_api::terms::TermId(1), wp_api::terms::TermId(2)]) + .with_tags(vec![wp_api::terms::TermId(10), wp_api::terms::TermId(20)]) + .build(); + + // Upsert with terms + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Verify post was inserted + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .unwrap(); + assert_eq!(retrieved.post.id, PostId(300)); + + // Verify categories were inserted + assert_eq!(retrieved.post.categories.as_ref().unwrap().len(), 2); + assert!( + retrieved + .post + .categories + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(1)) + ); + assert!( + retrieved + .post + .categories + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(2)) + ); + + // Verify tags were inserted + assert_eq!(retrieved.post.tags.as_ref().unwrap().len(), 2); + assert!( + retrieved + .post + .tags + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(10)) + ); + assert!( + retrieved + .post + .tags + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(20)) + ); + } + + #[rstest] + fn test_upsert_updates_existing_terms(mut test_ctx: TestContext) { + // Insert post with initial terms + let post = PostBuilder::minimal() + .with_id(400) + .with_categories(vec![wp_api::terms::TermId(1), wp_api::terms::TermId(2)]) + .with_tags(vec![ + wp_api::terms::TermId(10), + wp_api::terms::TermId(20), + wp_api::terms::TermId(30), + ]) + .build(); + + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Update with different terms + let updated_post = PostBuilder::minimal() + .with_id(400) + .with_categories(vec![wp_api::terms::TermId(1), wp_api::terms::TermId(3)]) // Remove 2, add 3 + .with_tags(vec![wp_api::terms::TermId(10)]) // Remove 20, 30 + .build(); + + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &updated_post) + .unwrap(); + + // Verify updated terms + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(400)) + .unwrap(); + + // Categories: should have 1, 3 (not 2) + assert_eq!(retrieved.post.categories.as_ref().unwrap().len(), 2); + assert!( + retrieved + .post + .categories + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(1)) + ); + assert!( + retrieved + .post + .categories + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(3)) + ); + assert!( + !retrieved + .post + .categories + .as_ref() + .unwrap() + .contains(&wp_api::terms::TermId(2)) + ); + + // Tags: should only have 10 (not 20, 30) + assert_eq!(retrieved.post.tags.as_ref().unwrap().len(), 1); + assert_eq!( + retrieved.post.tags.as_ref().unwrap()[0], + wp_api::terms::TermId(10) + ); + } + + #[rstest] + fn test_delete_by_post_id_deletes_terms(mut test_ctx: TestContext) { + // Insert post without terms (to avoid transaction issues in this test) + let post = PostBuilder::minimal().with_id(500).build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Manually add terms using WordPress post ID + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + post.id.0, + &wp_api::taxonomies::TaxonomyType::Category, + &[wp_api::terms::TermId(1), wp_api::terms::TermId(2)], + ) + .unwrap(); + tx.commit().unwrap(); + + // Verify terms exist + let terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, post.id.0) + .unwrap(); + assert!(!terms.is_empty()); + + // Delete post + test_ctx + .post_repo + .delete_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(500)) + .unwrap(); + + // Verify terms were also deleted + let terms_after = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, post.id.0) + .unwrap(); + assert!(terms_after.is_empty()); + } + + #[rstest] + fn test_select_by_rowid_populates_terms(mut test_ctx: TestContext) { + // Insert post with terms + let post = PostBuilder::minimal() + .with_id(600) + .with_categories(vec![wp_api::terms::TermId(5)]) + .build(); + + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Select by rowid should populate terms + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .unwrap(); + assert_eq!( + retrieved.post.categories, + Some(vec![wp_api::terms::TermId(5)]) + ); + } + + #[rstest] + fn test_insert_sets_last_fetched_at(mut test_ctx: TestContext) { + let post = PostBuilder::minimal().build(); + + // Insert post + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Retrieve and validate last_fetched_at + let retrieved = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, rowid) + .unwrap(); + + // Validate timestamp is recent and valid + assert_recent_timestamp(&retrieved.last_fetched_at); + } + + #[rstest] + fn test_upsert_updates_last_fetched_at_on_update(mut test_ctx: TestContext) { + let post = PostBuilder::minimal() + .with_id(200) + .with_title("Original Title") + .build(); + + // Initial insert + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + let first_fetch = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(200)) + .unwrap() + .last_fetched_at + .clone(); + + // Sleep a tiny bit to ensure timestamp changes + std::thread::sleep(std::time::Duration::from_millis(10)); + + // Update post + let updated_post = PostBuilder::minimal() + .with_id(200) + .with_title("Updated Title") + .build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &updated_post) + .unwrap(); + let second_fetch = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(200)) + .unwrap() + .last_fetched_at; + + // last_fetched_at should be updated (different) + assert_ne!(first_fetch, second_fetch); + + // Both should be valid timestamps + assert!(first_fetch.ends_with('Z')); + assert!(second_fetch.ends_with('Z')); + } +} diff --git a/wp_mobile_cache/src/repository/posts_constraint_tests.rs b/wp_mobile_cache/src/repository/posts_constraint_tests.rs new file mode 100644 index 000000000..9b78796ba --- /dev/null +++ b/wp_mobile_cache/src/repository/posts_constraint_tests.rs @@ -0,0 +1,136 @@ +//! Constraint violation and error handling tests for PostRepository. +//! +//! These tests verify that database constraints are enforced correctly +//! and that error cases are handled appropriately. + +use crate::{ + DbSite, RowId, + test_fixtures::{TestContext, posts::PostBuilder, test_ctx}, +}; +use rstest::*; +use wp_api::posts::PostId; + +#[rstest] +fn test_duplicate_post_id_in_same_site_updates_on_upsert(mut test_ctx: TestContext) { + let post_id = PostId(42); + + // Insert first post + let post1 = PostBuilder::minimal() + .with_id(42) + .with_title("Original Title") + .build(); + let rowid1 = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post1) + .unwrap(); + + // Upsert second post with same ID - should update existing post + let post2 = PostBuilder::minimal() + .with_id(42) + .with_title("Updated Title") + .build(); + let rowid2 = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post2) + .unwrap(); + + // Should return same rowid (updated existing row) + assert_eq!(rowid1, rowid2, "Upsert should update existing post"); + + // Verify only one post exists + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 1 + ); + + // Verify the title was updated + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, post_id) + .unwrap(); + assert_eq!(retrieved.post.title.rendered, "Updated Title"); +} + +#[rstest] +fn test_invalid_site_id_fails_foreign_key_constraint(mut test_ctx: TestContext) { + let non_existent_site = DbSite { row_id: RowId(999) }; // Site doesn't exist + + let post = PostBuilder::minimal().build(); + let result = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &non_existent_site, &post); + + assert!( + result.is_err(), + "Should fail with foreign key constraint violation" + ); + + let err = result.unwrap_err(); + assert!( + err.to_string().contains("FOREIGN KEY constraint failed") + || err.to_string().contains("foreign key"), + "Error should mention foreign key violation, got: {}", + err + ); +} + +#[rstest] +fn test_select_by_post_id_returns_error_for_non_existent_post(test_ctx: TestContext) { + let result = + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(99999)); + + assert!( + result.is_err(), + "Should return error when post doesn't exist" + ); +} + +#[rstest] +fn test_select_by_rowid_returns_error_for_non_existent_rowid(test_ctx: TestContext) { + let result = test_ctx + .post_repo + .select_by_rowid(&test_ctx.conn, &test_ctx.site, RowId(99999)); + + assert!( + result.is_err(), + "Should return error when rowid doesn't exist" + ); +} + +#[rstest] +fn test_delete_non_existent_post_returns_zero(test_ctx: TestContext) { + let deleted = test_ctx + .post_repo + .delete_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(99999)) + .unwrap(); + + assert_eq!( + deleted, 0, + "Should return 0 when deleting non-existent post" + ); +} + +#[rstest] +fn test_count_returns_zero_for_empty_site(test_ctx: TestContext) { + let count = test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(); + + assert_eq!(count, 0, "Empty site should have count of 0"); +} + +#[rstest] +fn test_select_all_returns_empty_for_empty_site(test_ctx: TestContext) { + let posts = test_ctx + .post_repo + .select_all(&test_ctx.conn, &test_ctx.site) + .unwrap(); + + assert_eq!(posts.len(), 0, "Empty site should return empty vector"); +} diff --git a/wp_mobile_cache/src/repository/posts_multi_site_tests.rs b/wp_mobile_cache/src/repository/posts_multi_site_tests.rs new file mode 100644 index 000000000..ffa00146c --- /dev/null +++ b/wp_mobile_cache/src/repository/posts_multi_site_tests.rs @@ -0,0 +1,200 @@ +//! Multi-site isolation tests for PostRepository. + +use crate::test_fixtures::{TestContext, create_test_site, posts::PostBuilder, test_ctx}; +use rstest::*; +use wp_api::posts::PostId; + +#[rstest] +fn test_posts_in_site_1_invisible_to_site_2(mut test_ctx: TestContext) { + let site2 = create_test_site(&test_ctx.conn, 2); + + // Insert post in site 1 + let post = PostBuilder::minimal().with_id(100).build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Site 2 should not see site 1's post + let result = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &site2, PostId(100)); + assert!( + result.is_err(), + "Site 2 should not be able to access Site 1's posts" + ); +} + +#[rstest] +fn test_same_post_id_can_exist_in_different_sites(mut test_ctx: TestContext) { + let site2 = create_test_site(&test_ctx.conn, 2); + + // Create post with same ID in both sites + let post_id = PostId(42); + let post1 = PostBuilder::minimal() + .with_id(42) + .with_title("Site 1 Post") + .build(); + let post2 = PostBuilder::minimal() + .with_id(42) + .with_title("Site 2 Post") + .build(); + + // Both inserts should succeed + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post1) + .expect("Site 1 insert should succeed"); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &site2, &post2) + .expect("Site 2 insert should succeed - same post ID in different site"); + + // Verify each site sees its own post + let retrieved1 = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, post_id) + .unwrap(); + let retrieved2 = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &site2, post_id) + .unwrap(); + + assert_eq!(retrieved1.post.title.rendered, "Site 1 Post"); + assert_eq!(retrieved2.post.title.rendered, "Site 2 Post"); +} + +#[rstest] +fn test_select_all_only_returns_posts_for_requested_site(mut test_ctx: TestContext) { + let site2 = create_test_site(&test_ctx.conn, 2); + + // Insert posts in site 1 + test_ctx + .post_repo + .upsert( + &mut test_ctx.conn, + &test_ctx.site, + &PostBuilder::minimal().build(), + ) + .unwrap(); + test_ctx + .post_repo + .upsert( + &mut test_ctx.conn, + &test_ctx.site, + &PostBuilder::minimal().build(), + ) + .unwrap(); + + // Insert posts in site 2 + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &site2, &PostBuilder::minimal().build()) + .unwrap(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &site2, &PostBuilder::minimal().build()) + .unwrap(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &site2, &PostBuilder::minimal().build()) + .unwrap(); + + // Verify counts + let site1_posts = test_ctx + .post_repo + .select_all(&test_ctx.conn, &test_ctx.site) + .unwrap(); + let site2_posts = test_ctx + .post_repo + .select_all(&test_ctx.conn, &site2) + .unwrap(); + + assert_eq!(site1_posts.len(), 2, "Site 1 should have 2 posts"); + assert_eq!(site2_posts.len(), 3, "Site 2 should have 3 posts"); +} + +#[rstest] +fn test_count_only_counts_posts_for_requested_site(mut test_ctx: TestContext) { + let site2 = create_test_site(&test_ctx.conn, 2); + + // Insert posts in both sites + test_ctx + .post_repo + .upsert( + &mut test_ctx.conn, + &test_ctx.site, + &PostBuilder::minimal().build(), + ) + .unwrap(); + test_ctx + .post_repo + .upsert( + &mut test_ctx.conn, + &test_ctx.site, + &PostBuilder::minimal().build(), + ) + .unwrap(); + + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &site2, &PostBuilder::minimal().build()) + .unwrap(); + + assert_eq!( + test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(), + 2 + ); + assert_eq!(test_ctx.post_repo.count(&test_ctx.conn, &site2).unwrap(), 1); +} + +#[rstest] +fn test_delete_by_post_id_only_deletes_from_specified_site(mut test_ctx: TestContext) { + let site2 = create_test_site(&test_ctx.conn, 2); + + let post_id = PostId(999); + + // Create post with same ID in both sites + test_ctx + .post_repo + .upsert( + &mut test_ctx.conn, + &test_ctx.site, + &PostBuilder::minimal().with_id(999).build(), + ) + .unwrap(); + test_ctx + .post_repo + .upsert( + &mut test_ctx.conn, + &site2, + &PostBuilder::minimal().with_id(999).build(), + ) + .unwrap(); + + // Delete from site 1 + let deleted = test_ctx + .post_repo + .delete_by_post_id(&test_ctx.conn, &test_ctx.site, post_id) + .unwrap(); + assert_eq!(deleted, 1); + + // Site 1 should no longer have the post + assert!( + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, post_id) + .is_err() + ); + + // Site 2 should still have its post + assert!( + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &site2, post_id) + .is_ok() + ); +} diff --git a/wp_mobile_cache/src/repository/posts_transaction_tests.rs b/wp_mobile_cache/src/repository/posts_transaction_tests.rs new file mode 100644 index 000000000..e7ee67647 --- /dev/null +++ b/wp_mobile_cache/src/repository/posts_transaction_tests.rs @@ -0,0 +1,213 @@ +//! Transaction handling tests for PostRepository. +//! +//! These tests verify both successful transactions and failure cases, +//! ensuring proper rollback on errors without leaving partial writes or corrupted data. + +use crate::{ + DbSite, RowId, + test_fixtures::{TestContext, posts::PostBuilder, test_ctx}, +}; +use rstest::*; +use wp_api::posts::PostId; + +#[rstest] +fn test_upsert_batch_handles_duplicate_ids_by_updating(mut test_ctx: TestContext) { + // Pre-insert a post with ID 200 + let existing_post = PostBuilder::minimal() + .with_id(200) + .with_title("Original") + .build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &existing_post) + .unwrap(); + + // Create batch where 2nd post has duplicate ID (200) with different title + let post1 = PostBuilder::minimal().with_id(100).build(); + let post2 = PostBuilder::minimal() + .with_id(200) + .with_title("Updated") + .build(); + let post3 = PostBuilder::minimal().with_id(300).build(); + + let posts = vec![post1, post2, post3]; + + // Batch upsert should succeed - duplicate is updated + let rowids = test_ctx + .post_repo + .upsert_batch(&mut test_ctx.conn, &test_ctx.site, &posts) + .unwrap(); + assert_eq!(rowids.len(), 3); + + // Verify all 3 posts exist (100, 200 updated, 300) + let count = test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(); + assert_eq!(count, 3, "Should have 3 posts total"); + + // Verify post 100 was inserted + assert!( + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(100)) + .is_ok() + ); + + // Verify post 200 was updated + let post200 = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(200)) + .unwrap(); + assert_eq!(post200.post.title.rendered, "Updated"); + + // Verify post 300 was inserted + assert!( + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(300)) + .is_ok() + ); +} + +#[rstest] +fn test_upsert_batch_fails_on_foreign_key_violation(mut test_ctx: TestContext) { + let invalid_site = DbSite { row_id: RowId(999) }; + + let post1 = PostBuilder::minimal().build(); + let post2 = PostBuilder::minimal().build(); + + let posts = vec![post1, post2]; + + // Batch upsert to invalid site should fail on first post + let result = test_ctx + .post_repo + .upsert_batch(&mut test_ctx.conn, &invalid_site, &posts); + + assert!( + result.is_err(), + "upsert_batch should fail with foreign key constraint" + ); + + // Verify no posts were inserted (fails fast on first error) + let count = test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(); + assert_eq!(count, 0, "No posts should exist after failure"); +} + +#[rstest] +fn test_upsert_maintains_consistency_on_success(mut test_ctx: TestContext) { + let post_id_500 = PostId(500); + + // Create post with terms + let post = PostBuilder::minimal() + .with_post_id(post_id_500) + .with_categories(vec![wp_api::terms::TermId(1), wp_api::terms::TermId(2)]) + .with_tags(vec![wp_api::terms::TermId(10)]) + .build(); + + // Upsert should succeed + let rowid = test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post) + .unwrap(); + + // Verify post exists + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, post_id_500) + .unwrap(); + assert_eq!(retrieved.post.id, post_id_500); + assert_eq!(retrieved.row_id, rowid); + + // Verify terms were synced correctly + assert_eq!( + retrieved.post.categories, + Some(vec![wp_api::terms::TermId(1), wp_api::terms::TermId(2)]) + ); + assert_eq!(retrieved.post.tags, Some(vec![wp_api::terms::TermId(10)])); + + // Update the post with different terms + let updated_post = PostBuilder::minimal() + .with_post_id(post_id_500) + .with_categories(vec![wp_api::terms::TermId(3)]) // Changed + .with_tags(vec![]) // Cleared + .build(); + + // Upsert again + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &updated_post) + .unwrap(); + + // Verify terms were updated correctly + let retrieved = test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, post_id_500) + .unwrap(); + assert_eq!( + retrieved.post.categories, + Some(vec![wp_api::terms::TermId(3)]) + ); + assert_eq!(retrieved.post.tags, None); + + // Verify old terms are gone (no orphaned relationships) + // The term_relationships table should only have the new category term + let all_terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, post_id_500.0) + .unwrap(); + + // Should only have one entry (Category with term 3) + assert_eq!( + all_terms.len(), + 1, + "Should only have category taxonomy after update" + ); + let categories = all_terms + .get(&wp_api::taxonomies::TaxonomyType::Category) + .unwrap(); + assert_eq!(categories.len(), 1); + assert_eq!(categories[0], wp_api::terms::TermId(3)); +} + +#[rstest] +fn test_insert_batch_succeeds_with_valid_posts(mut test_ctx: TestContext) { + // Create valid batch + let post1 = PostBuilder::minimal().with_id(100).build(); + let post2 = PostBuilder::minimal().with_id(200).build(); + let post3 = PostBuilder::minimal().with_id(300).build(); + + let posts = vec![post1, post2, post3]; + + // Should succeed + let rowids = test_ctx + .post_repo + .upsert_batch(&mut test_ctx.conn, &test_ctx.site, &posts) + .unwrap(); + + assert_eq!(rowids.len(), 3, "All 3 posts should be inserted"); + + // Verify all posts exist + let count = test_ctx + .post_repo + .count(&test_ctx.conn, &test_ctx.site) + .unwrap(); + assert_eq!(count, 3); + + // Verify each post can be retrieved + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(100)) + .expect("Post 100 should exist"); + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(200)) + .expect("Post 200 should exist"); + test_ctx + .post_repo + .select_by_post_id(&test_ctx.conn, &test_ctx.site, PostId(300)) + .expect("Post 300 should exist"); +} diff --git a/wp_mobile_cache/src/repository/term_relationships.rs b/wp_mobile_cache/src/repository/term_relationships.rs new file mode 100644 index 000000000..e86d9ef1d --- /dev/null +++ b/wp_mobile_cache/src/repository/term_relationships.rs @@ -0,0 +1,639 @@ +use crate::{ + DbSite, SqliteDbError, repository::QueryExecutor, term_relationships::DbTermRelationship, +}; +use std::collections::HashMap; +use wp_api::taxonomies::TaxonomyType; +use wp_api::terms::TermId; + +/// Repository for managing term relationships in the database. +/// +/// Provides methods for syncing, querying, and deleting term associations +/// between objects (posts, pages, etc.) and WordPress terms. +pub struct TermRelationshipRepository; + +impl TermRelationshipRepository { + const TABLE_NAME: &'static str = "term_relationships"; + + /// Synchronize terms for an object (only insert new, delete removed, keep unchanged). + /// + /// This approach is observer-friendly: unchanged terms generate no DB events. + /// Only actual changes (new terms added, old terms removed) generate INSERT/DELETE events. + /// + /// **IMPORTANT**: This method must be called within a transaction to ensure atomicity. + /// The transaction parameter enforces this requirement at compile-time. + /// + /// # Arguments + /// * `object_id` - WordPress object ID (e.g., post.id), NOT SQLite rowid + pub fn sync_terms_for_object( + &self, + transaction: &rusqlite::Transaction<'_>, + site: &DbSite, + object_id: i64, + taxonomy_type: &TaxonomyType, + new_term_ids: &[TermId], + ) -> Result<(), SqliteDbError> { + // 1. Get existing term IDs + let existing_terms = + self.get_terms_for_object(transaction, site, object_id, taxonomy_type)?; + + // 2. Calculate diff (using Vec-based filtering since TermId may not impl Hash) + let to_delete: Vec<_> = existing_terms + .iter() + .filter(|existing| !new_term_ids.contains(existing)) + .copied() + .collect(); + + let to_insert: Vec<_> = new_term_ids + .iter() + .filter(|new_id| !existing_terms.contains(new_id)) + .copied() + .collect(); + + // 3. Delete removed terms (only the ones being removed) + if !to_delete.is_empty() { + self.delete_terms(transaction, site, object_id, taxonomy_type, &to_delete)?; + } + + // 4. Insert new terms (only the ones being added) + if !to_insert.is_empty() { + self.insert_terms(transaction, site, object_id, taxonomy_type, &to_insert)?; + } + + // Unchanged terms: no DB operations = no observer events + Ok(()) + } + + /// Delete specific terms for an object. + /// + /// # Arguments + /// * `object_id` - WordPress object ID (e.g., post.id), NOT SQLite rowid + fn delete_terms( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + object_id: i64, + taxonomy_type: &TaxonomyType, + term_ids: &[TermId], + ) -> Result<(), SqliteDbError> { + if term_ids.is_empty() { + return Ok(()); + } + + // Build placeholders for IN clause + let placeholders: Vec<_> = (0..term_ids.len()).map(|_| "?").collect(); + let sql = format!( + "DELETE FROM {} WHERE db_site_id = ? AND object_id = ? AND taxonomy_type = ? AND term_id IN ({})", + Self::TABLE_NAME, + placeholders.join(", ") + ); + + // Build params: [site_id, object_id, taxonomy_type, term_id1, term_id2, ...] + let mut params: Vec> = vec![ + Box::new(site.row_id), + Box::new(object_id), + Box::new(taxonomy_type.to_string()), + ]; + params.extend( + term_ids + .iter() + .map(|term_id| Box::new(term_id.0) as Box), + ); + + let params_refs: Vec<_> = params.iter().map(|p| p.as_ref()).collect(); + executor.execute(&sql, params_refs.as_slice())?; + Ok(()) + } + + /// Insert new terms for an object. + /// + /// # Arguments + /// * `object_id` - WordPress object ID (e.g., post.id), NOT SQLite rowid + fn insert_terms( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + object_id: i64, + taxonomy_type: &TaxonomyType, + term_ids: &[TermId], + ) -> Result<(), SqliteDbError> { + if term_ids.is_empty() { + return Ok(()); + } + + let insert_sql = format!( + "INSERT INTO {} (db_site_id, object_id, term_id, taxonomy_type) VALUES (?, ?, ?, ?)", + Self::TABLE_NAME + ); + + term_ids.iter().try_for_each(|term_id| { + executor.execute( + &insert_sql, + rusqlite::params![site.row_id, object_id, term_id.0, taxonomy_type.to_string()], + )?; + Ok::<_, SqliteDbError>(()) + }) + } + + /// Get all term IDs for an object's taxonomy. + /// + /// # Arguments + /// * `object_id` - WordPress object ID (e.g., post.id), NOT SQLite rowid + pub fn get_terms_for_object( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + object_id: i64, + taxonomy_type: &TaxonomyType, + ) -> Result, SqliteDbError> { + let sql = format!( + "SELECT term_id FROM {} WHERE db_site_id = ? AND object_id = ? AND taxonomy_type = ?", + Self::TABLE_NAME + ); + let mut stmt = executor.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params![site.row_id, object_id, taxonomy_type.to_string()], + |row| { + let id: i64 = row.get(0)?; + Ok(TermId(id)) + }, + )?; + + rows.collect::, _>>() + .map_err(SqliteDbError::from) + } + + /// Get all term IDs grouped by taxonomy for an object (for post reads with joins). + /// + /// # Arguments + /// * `object_id` - WordPress object ID (e.g., post.id), NOT SQLite rowid + pub fn get_all_terms_for_object( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + object_id: i64, + ) -> Result>, SqliteDbError> { + let sql = format!( + "SELECT taxonomy_type, term_id FROM {} WHERE db_site_id = ? AND object_id = ?", + Self::TABLE_NAME + ); + let mut stmt = executor.prepare(&sql)?; + let mut rows = stmt.query_map(rusqlite::params![site.row_id, object_id], |row| { + let taxonomy_str: String = row.get(0)?; + let term_id: i64 = row.get(1)?; + Ok((taxonomy_str, term_id)) + })?; + + rows.try_fold( + HashMap::new(), + |mut result: HashMap>, row_result| { + let (taxonomy_str, term_id) = row_result.map_err(SqliteDbError::from)?; + let taxonomy_type: TaxonomyType = + serde_json::from_value(serde_json::Value::String(taxonomy_str.clone())) + .map_err(|e| { + SqliteDbError::SqliteError(format!( + "Invalid taxonomy_type '{}': {}", + taxonomy_str, e + )) + })?; + + result + .entry(taxonomy_type) + .or_default() + .push(TermId(term_id)); + Ok::<_, SqliteDbError>(result) + }, + ) + } + + /// Delete all terms for an object (called when deleting the object itself). + /// + /// # Arguments + /// * `object_id` - WordPress object ID (e.g., post.id), NOT SQLite rowid + pub fn delete_all_terms_for_object( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + object_id: i64, + ) -> Result { + let sql = format!( + "DELETE FROM {} WHERE db_site_id = ? AND object_id = ?", + Self::TABLE_NAME + ); + executor.execute(&sql, rusqlite::params![site.row_id, object_id]) + } + + /// Get all term relationships for multiple objects in a single batch query. + /// + /// This is a generic method that loads complete `DbTermRelationship` entities + /// for the specified objects. Domain-specific logic (e.g., separating categories + /// from tags) should be handled in the mapping layer. + /// + /// # Arguments + /// * `object_ids` - WordPress object IDs (e.g., post.id, page.id), NOT SQLite rowids + /// + /// Returns a HashMap mapping object_id (as i64) to its term relationships. + pub fn get_terms_for_objects( + &self, + executor: &impl QueryExecutor, + site: &DbSite, + object_ids: &[i64], + ) -> Result>, SqliteDbError> { + if object_ids.is_empty() { + return Ok(HashMap::new()); + } + + let ids_str = object_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + + let sql = format!( + "SELECT * FROM {} WHERE db_site_id = ? AND object_id IN ({})", + Self::TABLE_NAME, + ids_str + ); + + let mut stmt = executor.prepare(&sql)?; + let mut rows = stmt.query_map([site.row_id], |row| { + DbTermRelationship::from_row(row) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + })?; + + // Group term relationships by object_id + rows.try_fold( + HashMap::new(), + |mut acc: HashMap>, row_result| { + let relationship = row_result.map_err(SqliteDbError::from)?; + acc.entry(relationship.object_id.0 as i64) + .or_default() + .push(relationship); + Ok::<_, SqliteDbError>(acc) + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_fixtures::{TestContext, test_ctx}; + use rstest::*; + + #[rstest] + fn test_sync_terms_insert_new(mut test_ctx: TestContext) { + let test_object_id = 42; + + let term_ids = vec![TermId(1), TermId(2), TermId(3)]; + + // Sync terms (should insert all) + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + &term_ids, + ) + .unwrap(); + tx.commit().unwrap(); + + // Verify all were inserted + let retrieved = test_ctx + .term_repo + .get_terms_for_object( + &test_ctx.conn, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + ) + .unwrap(); + + assert_eq!(retrieved.len(), 3); + assert!(retrieved.contains(&TermId(1))); + assert!(retrieved.contains(&TermId(2))); + assert!(retrieved.contains(&TermId(3))); + } + + #[rstest] + fn test_sync_terms_remove_old(mut test_ctx: TestContext) { + let test_object_id = 42; + + // Insert initial terms + let initial_terms = vec![TermId(1), TermId(2), TermId(3)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &initial_terms, + ) + .unwrap(); + tx.commit().unwrap(); + + // Sync with fewer terms (remove 2 and 3) + let updated_terms = vec![TermId(1)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &updated_terms, + ) + .unwrap(); + tx.commit().unwrap(); + + // Verify only term 1 remains + let retrieved = test_ctx + .term_repo + .get_terms_for_object( + &test_ctx.conn, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + ) + .unwrap(); + + assert_eq!(retrieved.len(), 1); + assert_eq!(retrieved[0], TermId(1)); + } + + #[rstest] + fn test_sync_terms_add_new_keep_existing(mut test_ctx: TestContext) { + let test_object_id = 42; + + // Insert initial terms + let initial_terms = vec![TermId(1), TermId(2)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + &initial_terms, + ) + .unwrap(); + tx.commit().unwrap(); + + // Sync with additional terms (keep 1, 2, add 3, 4) + let updated_terms = vec![TermId(1), TermId(2), TermId(3), TermId(4)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + &updated_terms, + ) + .unwrap(); + tx.commit().unwrap(); + + // Verify all four are present + let retrieved = test_ctx + .term_repo + .get_terms_for_object( + &test_ctx.conn, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + ) + .unwrap(); + + assert_eq!(retrieved.len(), 4); + assert!(retrieved.contains(&TermId(1))); + assert!(retrieved.contains(&TermId(2))); + assert!(retrieved.contains(&TermId(3))); + assert!(retrieved.contains(&TermId(4))); + } + + #[rstest] + fn test_sync_terms_no_changes(mut test_ctx: TestContext) { + let test_object_id = 42; + + // Insert initial terms + let terms = vec![TermId(1), TermId(2), TermId(3)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &terms, + ) + .unwrap(); + tx.commit().unwrap(); + + // Sync with same terms (no changes) + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &terms, + ) + .unwrap(); + tx.commit().unwrap(); + + // Verify terms unchanged + let retrieved = test_ctx + .term_repo + .get_terms_for_object( + &test_ctx.conn, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + ) + .unwrap(); + + assert_eq!(retrieved.len(), 3); + } + + #[rstest] + fn test_get_all_terms_for_object(mut test_ctx: TestContext) { + let test_object_id = 42; + + // Add categories + let categories = vec![TermId(1), TermId(2)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + &categories, + ) + .unwrap(); + tx.commit().unwrap(); + + // Add tags + let tags = vec![TermId(10), TermId(20), TermId(30)]; + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &tags, + ) + .unwrap(); + tx.commit().unwrap(); + + // Get all terms + let all_terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, test_object_id) + .unwrap(); + + // Verify categories + assert_eq!(all_terms.get(&TaxonomyType::Category).unwrap().len(), 2); + assert!( + all_terms + .get(&TaxonomyType::Category) + .unwrap() + .contains(&TermId(1)) + ); + assert!( + all_terms + .get(&TaxonomyType::Category) + .unwrap() + .contains(&TermId(2)) + ); + + // Verify tags + assert_eq!(all_terms.get(&TaxonomyType::PostTag).unwrap().len(), 3); + assert!( + all_terms + .get(&TaxonomyType::PostTag) + .unwrap() + .contains(&TermId(10)) + ); + assert!( + all_terms + .get(&TaxonomyType::PostTag) + .unwrap() + .contains(&TermId(20)) + ); + assert!( + all_terms + .get(&TaxonomyType::PostTag) + .unwrap() + .contains(&TermId(30)) + ); + } + + #[rstest] + fn test_delete_all_terms_for_object(mut test_ctx: TestContext) { + let test_object_id = 42; + + // Add terms + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + &[TermId(1)], + ) + .unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &[TermId(10)], + ) + .unwrap(); + tx.commit().unwrap(); + + // Delete all terms + let deleted = test_ctx + .term_repo + .delete_all_terms_for_object(&test_ctx.conn, &test_ctx.site, test_object_id) + .unwrap(); + assert_eq!(deleted, 2); + + // Verify all deleted + let all_terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, test_object_id) + .unwrap(); + assert!(all_terms.is_empty()); + } + + #[rstest] + fn test_different_taxonomy_types_are_isolated(mut test_ctx: TestContext) { + let test_object_id = 42; + + // Add same term ID to different taxonomies + let tx = test_ctx.conn.transaction().unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + &[TermId(1)], + ) + .unwrap(); + test_ctx + .term_repo + .sync_terms_for_object( + &tx, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + &[TermId(1)], + ) + .unwrap(); + tx.commit().unwrap(); + + // Verify both exist independently + let categories = test_ctx + .term_repo + .get_terms_for_object( + &test_ctx.conn, + &test_ctx.site, + test_object_id, + &TaxonomyType::Category, + ) + .unwrap(); + let tags = test_ctx + .term_repo + .get_terms_for_object( + &test_ctx.conn, + &test_ctx.site, + test_object_id, + &TaxonomyType::PostTag, + ) + .unwrap(); + + assert_eq!(categories.len(), 1); + assert_eq!(tags.len(), 1); + } +} diff --git a/wp_mobile_cache/src/repository/term_relationships_multi_site_tests.rs b/wp_mobile_cache/src/repository/term_relationships_multi_site_tests.rs new file mode 100644 index 000000000..73e52669f --- /dev/null +++ b/wp_mobile_cache/src/repository/term_relationships_multi_site_tests.rs @@ -0,0 +1,77 @@ +//! Multi-site isolation tests for TermRelationshipRepository. +//! +//! These tests verify that term relationships are correctly isolated between sites. + +use crate::test_fixtures::{TestContext, create_test_site, posts::PostBuilder, test_ctx}; +use rstest::*; +use wp_api::{taxonomies::TaxonomyType, terms::TermId}; + +#[rstest] +fn test_term_relationships_isolated_by_site(mut test_ctx: TestContext) { + let site2 = create_test_site(&test_ctx.conn, 2); + + // Insert post in site 1 with categories + let post1 = PostBuilder::minimal() + .with_id(100) + .with_categories(vec![TermId(1), TermId(2)]) + .build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &test_ctx.site, &post1) + .unwrap(); + + // Insert post in site 2 with same categories + let post2 = PostBuilder::minimal() + .with_id(200) + .with_categories(vec![TermId(1), TermId(2)]) + .build(); + test_ctx + .post_repo + .upsert(&mut test_ctx.conn, &site2, &post2) + .unwrap(); + + // Verify site 1's terms using WordPress post ID + let site1_terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, 100) + .unwrap(); + let site1_categories = site1_terms.get(&TaxonomyType::Category).unwrap(); + assert_eq!(site1_categories.len(), 2); + assert!(site1_categories.contains(&TermId(1))); + assert!(site1_categories.contains(&TermId(2))); + + // Verify site 2's terms using WordPress post ID + let site2_terms = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &site2, 200) + .unwrap(); + let site2_categories = site2_terms.get(&TaxonomyType::Category).unwrap(); + assert_eq!(site2_categories.len(), 2); + assert!(site2_categories.contains(&TermId(1))); + assert!(site2_categories.contains(&TermId(2))); + + // Delete terms for site 1's post + test_ctx + .term_repo + .delete_all_terms_for_object(&test_ctx.conn, &test_ctx.site, 100) + .unwrap(); + + // Verify site 1 has no terms + let site1_terms_after = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &test_ctx.site, 100) + .unwrap(); + assert_eq!(site1_terms_after.len(), 0); + + // Verify site 2 still has its terms (not affected by site 1's deletion) + let site2_terms_after = test_ctx + .term_repo + .get_all_terms_for_object(&test_ctx.conn, &site2, 200) + .unwrap(); + let site2_categories_after = site2_terms_after.get(&TaxonomyType::Category).unwrap(); + assert_eq!( + site2_categories_after.len(), + 2, + "Site 2's terms should not be affected by Site 1's term deletion" + ); +} diff --git a/wp_mobile_cache/src/term_relationships.rs b/wp_mobile_cache/src/term_relationships.rs new file mode 100644 index 000000000..36ea0d116 --- /dev/null +++ b/wp_mobile_cache/src/term_relationships.rs @@ -0,0 +1,21 @@ +use crate::{DbSite, RowId}; +use wp_api::taxonomies::TaxonomyType; +use wp_api::terms::TermId; + +/// Represents a term relationship in the database. +/// +/// This associates an object (post, page, nav item, etc.) with a WordPress term +/// for a specific taxonomy (category, tag, custom taxonomy). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbTermRelationship { + /// SQLite rowid of this relationship + pub row_id: RowId, + /// Site this relationship belongs to + pub site: DbSite, + /// Row ID of the object (post, page, etc.) in its respective table + pub object_id: RowId, + /// WordPress term ID + pub term_id: TermId, + /// Taxonomy type (category, post_tag, or custom) + pub taxonomy_type: TaxonomyType, +} diff --git a/wp_mobile_cache/src/test_fixtures.rs b/wp_mobile_cache/src/test_fixtures.rs new file mode 100644 index 000000000..7e63fb377 --- /dev/null +++ b/wp_mobile_cache/src/test_fixtures.rs @@ -0,0 +1,101 @@ +use crate::{ + DbSite, MigrationManager, RowId, + repository::{posts::PostRepository, term_relationships::TermRelationshipRepository}, +}; +use chrono::{DateTime, Utc}; +use rstest::*; +use rusqlite::Connection; + +pub mod posts; + +/// Test context bundling common test dependencies. +/// +/// Reduces boilerplate when you need connection, site, and repositories. +/// +/// # Example +/// +/// ```rust +/// #[rstest] +/// fn test_something(test_ctx: TestContext) { +/// let post = PostBuilder::new().build(); +/// test_ctx.post_repo.upsert(&mut test_ctx.conn, &test_ctx.site, &post).unwrap(); +/// } +/// ``` +pub struct TestContext { + pub conn: Connection, + pub site: DbSite, + pub post_repo: PostRepository, + pub term_repo: TermRelationshipRepository, +} + +#[fixture] +pub fn test_ctx() -> TestContext { + TestContext { + conn: test_db(), + site: DbSite { row_id: RowId(1) }, + post_repo: PostRepository, + term_repo: TermRelationshipRepository, + } +} + +fn test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + let mut migration_manager = MigrationManager::new(&conn).unwrap(); + + migration_manager + .perform_migrations() + .expect("All migrations should succeed"); + + // Insert default test site (id = 1) + conn.execute("INSERT INTO sites (id) VALUES (1)", []) + .expect("Failed to insert test site"); + + conn +} + +/// Helper to create an additional test site with a specific ID. +/// +/// Useful when you need more than 2 sites or specific site IDs. +pub fn create_test_site(conn: &Connection, id: i64) -> DbSite { + conn.execute("INSERT INTO sites (id) VALUES (?)", [id]) + .expect("Failed to create test site"); + DbSite { + row_id: RowId(id as u64), + } +} + +/// Validates that a timestamp is a recent, valid ISO 8601 UTC timestamp. +/// +/// Checks that the timestamp: +/// - Is in valid ISO 8601 format +/// - Is in UTC (ends with 'Z') +/// - Is within the last 5 seconds of the current time +/// +/// # Panics +/// +/// Panics if the timestamp is invalid or not recent. +pub fn assert_recent_timestamp(timestamp: &str) { + // Parse the timestamp + let parsed = DateTime::parse_from_rfc3339(timestamp) + .unwrap_or_else(|e| panic!("Failed to parse timestamp '{}': {}", timestamp, e)); + + // Verify it's UTC (ends with Z) + assert!( + timestamp.ends_with('Z'), + "Timestamp should be UTC (end with Z): {}", + timestamp + ); + + // Check that it's recent (within last 5 seconds) + let now = Utc::now(); + let timestamp_utc = parsed.with_timezone(&Utc); + let diff = now.signed_duration_since(timestamp_utc); + + assert!( + diff.num_seconds() >= 0 && diff.num_seconds() <= 5, + "Timestamp should be within last 5 seconds. Now: {}, Timestamp: {}, Diff: {} seconds", + now, + timestamp_utc, + diff.num_seconds() + ); +} diff --git a/wp_mobile_cache/src/test_fixtures/posts.rs b/wp_mobile_cache/src/test_fixtures/posts.rs new file mode 100644 index 000000000..529ac3bf2 --- /dev/null +++ b/wp_mobile_cache/src/test_fixtures/posts.rs @@ -0,0 +1,266 @@ +use std::sync::atomic::{AtomicI64, Ordering}; +use wp_api::{ + media::MediaId, + posts::{ + AnyPostWithEditContext, PostContentWithEditContext, PostFootnote, PostGuidWithEditContext, + PostId, PostMeta, PostStatus, PostTitleWithEditContext, SparsePostExcerpt, + }, + terms::TermId, + users::UserId, +}; + +/// Initial state for PostBuilder - determines which field values are populated. +pub enum PostBuilderInitialState { + /// Minimal valid post with only required fields populated + Minimal, + /// Fully populated post with all optional fields set + Full, +} + +/// Builder for creating test posts with automatic ID management. +/// +/// Use `PostBuilder::minimal()` for posts with only required fields, +/// or `PostBuilder::full()` for posts with all fields populated. +/// +/// Reduces boilerplate and prevents ID collisions in tests by auto-incrementing IDs. +/// +/// # Example +/// +/// ```rust +/// // Minimal post with custom fields +/// let post1 = PostBuilder::minimal() +/// .with_author(UserId(10)) +/// .build(); +/// +/// // Full post with overrides +/// let post2 = PostBuilder::full() +/// .with_status(PostStatus::Draft) +/// .build(); +/// +/// // IDs are automatically unique (1000, 1001, ...) +/// ``` +pub struct PostBuilder { + post: AnyPostWithEditContext, +} + +impl PostBuilder { + /// Create a new builder with auto-incremented ID starting from 1000. + /// + /// Uses thread-safe atomic counter to ensure unique IDs across tests. + /// + /// **Note**: Prefer using `PostBuilder::minimal()` or `PostBuilder::full()` + /// instead of calling this method directly. + pub fn new(initial_state: PostBuilderInitialState) -> Self { + static COUNTER: AtomicI64 = AtomicI64::new(1000); + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + + let mut post = match initial_state { + PostBuilderInitialState::Minimal => create_minimal_post(), + PostBuilderInitialState::Full => create_full_post(), + }; + post.id = PostId(id); + Self { post } + } + + /// Create a minimal post builder with only required fields populated. + /// + /// This is the most common starting point for test posts. + pub fn minimal() -> Self { + Self::new(PostBuilderInitialState::Minimal) + } + + /// Create a full post builder with all optional fields populated. + /// + /// Useful for testing complete post serialization/deserialization. + pub fn full() -> Self { + Self::new(PostBuilderInitialState::Full) + } + + /// Set a specific post ID (overrides auto-increment). + pub fn with_id(mut self, id: i64) -> Self { + self.post.id = PostId(id); + self + } + + /// Set a specific post ID (overrides auto-increment). + pub fn with_post_id(mut self, post_id: PostId) -> Self { + self.post.id = post_id; + self + } + + /// Set the post author. + pub fn with_author(mut self, author: UserId) -> Self { + self.post.author = Some(author); + self + } + + /// Set the post status. + pub fn with_status(mut self, status: PostStatus) -> Self { + self.post.status = status; + self + } + + /// Set the post title. + pub fn with_title(mut self, title: &str) -> Self { + self.post.title.rendered = title.to_string(); + self.post.title.raw = Some(title.to_string()); + self + } + + /// Set the post slug. + pub fn with_slug(mut self, slug: &str) -> Self { + self.post.slug = slug.to_string(); + self + } + + /// Set post categories. + pub fn with_categories(mut self, categories: Vec) -> Self { + self.post.categories = Some(categories); + self + } + + /// Set post tags. + pub fn with_tags(mut self, tags: Vec) -> Self { + self.post.tags = Some(tags); + self + } + + /// Set both categories and tags. + pub fn with_terms(mut self, categories: Vec, tags: Vec) -> Self { + self.post.categories = Some(categories); + self.post.tags = Some(tags); + self + } + + /// Set featured media. + pub fn with_featured_media(mut self, media_id: MediaId) -> Self { + self.post.featured_media = Some(media_id); + self + } + + /// Set parent post. + pub fn with_parent(mut self, parent_id: PostId) -> Self { + self.post.parent = Some(parent_id); + self + } + + /// Set sticky status. + pub fn with_sticky(mut self, sticky: bool) -> Self { + self.post.sticky = Some(sticky); + self + } + + /// Build the final AnyPostWithEditContext. + pub fn build(self) -> AnyPostWithEditContext { + self.post + } +} + +impl Default for PostBuilder { + fn default() -> Self { + Self::minimal() + } +} + +fn create_minimal_post() -> AnyPostWithEditContext { + AnyPostWithEditContext { + id: PostId(1), + date: "2024-01-01T00:00:00".to_string(), + date_gmt: "2024-01-01T00:00:00Z".parse().unwrap(), + guid: PostGuidWithEditContext { + raw: None, + rendered: "https://example.com/?p=1".to_string(), + }, + link: "https://example.com/minimal-post".to_string(), + modified: "2024-01-01T00:00:00".to_string(), + modified_gmt: "2024-01-01T00:00:00Z".parse().unwrap(), + slug: "minimal-post".to_string(), + status: PostStatus::Publish, + post_type: "post".to_string(), + password: "".to_string(), + permalink_template: None, + generated_slug: None, + title: PostTitleWithEditContext { + raw: None, + rendered: "Minimal Post".to_string(), + }, + content: PostContentWithEditContext { + raw: None, + rendered: "

Content

".to_string(), + protected: None, + block_version: None, + }, + author: None, + excerpt: None, + featured_media: None, + comment_status: None, + ping_status: None, + format: None, + meta: None, + sticky: None, + template: "".to_string(), + categories: None, + tags: None, + parent: None, + menu_order: None, + } +} + +fn create_full_post() -> AnyPostWithEditContext { + AnyPostWithEditContext { + id: PostId(42), + date: "2024-01-15T10:30:00".to_string(), + date_gmt: "2024-01-15T10:30:00Z".parse().unwrap(), + guid: PostGuidWithEditContext { + raw: Some("https://example.com/?p=42".to_string()), + rendered: "https://example.com/?p=42".to_string(), + }, + link: "https://example.com/full-post".to_string(), + modified: "2024-01-16T14:20:00".to_string(), + modified_gmt: "2024-01-16T14:20:00Z".parse().unwrap(), + slug: "full-post".to_string(), + status: PostStatus::Draft, + post_type: "post".to_string(), + password: "secret".to_string(), + permalink_template: Some("https://example.com/%postname%/".to_string()), + generated_slug: Some("full-post-123".to_string()), + title: PostTitleWithEditContext { + raw: Some("Full Post Title".to_string()), + rendered: "Full Post Title".to_string(), + }, + content: PostContentWithEditContext { + raw: Some("

Content

".to_string()), + rendered: "

Content

".to_string(), + protected: Some(false), + block_version: Some(1), + }, + author: Some(UserId(10)), + excerpt: Some(SparsePostExcerpt { + raw: Some("Excerpt raw".to_string()), + rendered: Some("

Excerpt

".to_string()), + protected: Some(false), + }), + featured_media: Some(MediaId(100)), + comment_status: Some(wp_api::posts::PostCommentStatus::Open), + ping_status: Some(wp_api::posts::PostPingStatus::Closed), + format: Some(wp_api::posts::PostFormat::Standard), + meta: Some(PostMeta { + footnotes: Some(vec![ + PostFootnote { + id: "fn1".to_string(), + content: "Footnote 1".to_string(), + }, + PostFootnote { + id: "fn2".to_string(), + content: "Footnote 2".to_string(), + }, + ]), + }), + sticky: Some(true), + template: "custom-template.php".to_string(), + categories: Some(vec![TermId(1), TermId(2), TermId(3)]), + tags: Some(vec![TermId(10), TermId(20)]), + parent: Some(PostId(5)), + menu_order: Some(3), + } +}