From e572437f371327966f6aeeb274bdce785992ca80 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 30 Mar 2026 00:02:12 -0400 Subject: [PATCH] linux db --- .github/workflows/ci.yml | 6 +- native-route/linux/meson.build | 5 +- native-route/linux/rssuper-database.vapi | 69 ++ native-route/linux/src/database/database.vala | 44 +- native-route/linux/src/database/db-error.vala | 24 + .../linux/src/database/feed-item-store.vala | 14 +- .../src/database/search-history-store.vala | 6 +- native-route/linux/src/database/sqlite3.vapi | 12 +- .../src/database/subscription-store.vala | 10 +- .../linux/src/tests/database-tests.vala | 642 +++++++++++------- 10 files changed, 529 insertions(+), 303 deletions(-) create mode 100644 native-route/linux/rssuper-database.vapi create mode 100644 native-route/linux/src/database/db-error.vala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4d39b2..fd21b25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,7 +229,7 @@ jobs: # macOS Build Job (using same iOS project) build-macos: name: Build macOS - runs-on: macos-15 + runs-on: macos steps: - name: Checkout code @@ -294,7 +294,7 @@ jobs: # Linux Build Job build-linux: name: Build Linux - runs-on: ubuntu-24.04 + runs-on: ubuntu steps: - name: Checkout code @@ -326,7 +326,7 @@ jobs: # Summary Job build-summary: name: Build Summary - runs-on: ubuntu-24.04 + runs-on: ubuntu needs: [build-ios, build-macos, build-android, build-linux] if: always() diff --git a/native-route/linux/meson.build b/native-route/linux/meson.build index 415b493..d4af03f 100644 --- a/native-route/linux/meson.build +++ b/native-route/linux/meson.build @@ -30,12 +30,11 @@ models = files( # Database files database = files( + 'src/database/db-error.vala', 'src/database/database.vala', 'src/database/subscription-store.vala', 'src/database/feed-item-store.vala', 'src/database/search-history-store.vala', - 'src/database/sqlite3.vapi', - 'src/database/errors.vapi', ) # Main library @@ -57,7 +56,7 @@ test_exe = executable('database-tests', 'src/tests/database-tests.vala', dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep, gobject_dep], link_with: [models_lib, database_lib], - vala_args: ['--vapidir', 'src/database', '--pkg', 'sqlite3'], + vala_args: ['--vapidir', '.', '--pkg', 'sqlite3'], install: false ) diff --git a/native-route/linux/rssuper-database.vapi b/native-route/linux/rssuper-database.vapi new file mode 100644 index 0000000..6d3014f --- /dev/null +++ b/native-route/linux/rssuper-database.vapi @@ -0,0 +1,69 @@ +/* + * RSSuper Database vapi - exports SQLite bindings for use by dependent modules + */ + +[CCode (cheader_filename = "sqlite3.h")] +namespace SQLite { + [CCode (cname = "sqlite3", free_function = "sqlite3_close")] + public class DB { + [CCode (cname = "sqlite3_open")] + public static int open(string filename, out DB db); + + [CCode (cname = "sqlite3_close")] + public int close(); + + [CCode (cname = "sqlite3_exec")] + public int exec(string sql, DBCallback? callback = null, void* arg = null, [CCode (array_length = false)] out string? errmsg = null); + + [CCode (cname = "sqlite3_errmsg")] + public unowned string errmsg(); + + [CCode (cname = "sqlite3_prepare_v2")] + public int prepare_v2(string zSql, int nByte, out Stmt stmt, void* pzTail = null); + } + + [CCode (cname = "sqlite3_stmt", free_function = "sqlite3_finalize")] + public class Stmt { + [CCode (cname = "sqlite3_step")] + public int step(); + + [CCode (cname = "sqlite3_column_count")] + public int column_count(); + + [CCode (cname = "sqlite3_column_text")] + public unowned string column_text(int i); + + [CCode (cname = "sqlite3_column_int")] + public int column_int(int i); + + [CCode (cname = "sqlite3_column_double")] + public double column_double(int i); + + [CCode (cname = "sqlite3_bind_text")] + public int bind_text(int i, string z, int n, void* x); + + [CCode (cname = "sqlite3_bind_int")] + public int bind_int(int i, int val); + + [CCode (cname = "sqlite3_bind_double")] + public int bind_double(int i, double val); + + [CCode (cname = "sqlite3_bind_null")] + public int bind_null(int i); + + [CCode (cname = "sqlite3_finalize")] + public int finalize(); + } + + [CCode (cname = "SQLITE_OK")] + public const int SQLITE_OK; + [CCode (cname = "SQLITE_ROW")] + public const int SQLITE_ROW; + [CCode (cname = "SQLITE_DONE")] + public const int SQLITE_DONE; + [CCode (cname = "SQLITE_ERROR")] + public const int SQLITE_ERROR; + + [CCode (simple_type = true)] + public delegate int DBCallback(void* arg, int argc, string[] argv, string[] col_names); +} diff --git a/native-route/linux/src/database/database.vala b/native-route/linux/src/database/database.vala index 9f9eb84..ed55c1e 100644 --- a/native-route/linux/src/database/database.vala +++ b/native-route/linux/src/database/database.vala @@ -9,7 +9,7 @@ * Database - Manages SQLite database connection and migrations */ public class RSSuper.Database : Object { - private SQLite.DB db; + private Sqlite.Database db; private string db_path; /** @@ -48,13 +48,13 @@ public class RSSuper.Database : Object { try { parent.make_directory_with_parents(); } catch (Error e) { - throw throw new Error.FAILED("Failed to create database directory: %s", e.message); + throw new DBError.FAILED("Failed to create database directory: %s", e.message); } } - int result = SQLite.DB.open(db_path, out db); - if (result != SQLite.SQLITE_OK) { - throw throw new Error.FAILED("Failed to open database: %s".printf(db.errmsg())); + int result = Sqlite.Database.open(db_path, out db); + if (result != Sqlite.OK) { + throw new DBError.FAILED("Failed to open database: %s".printf(db.errmsg())); } execute("PRAGMA foreign_keys = ON;"); @@ -90,7 +90,7 @@ public class RSSuper.Database : Object { } if (!schema_file.query_exists()) { - throw throw new Error.FAILED("Schema file not found: %s".printf(schema_path)); + throw new DBError.FAILED("Schema file not found: %s".printf(schema_path)); } uint8[] schema_bytes; @@ -99,7 +99,7 @@ public class RSSuper.Database : Object { try { schema_file.load_contents(cancellable, out schema_bytes, out schema_str); } catch (Error e) { - throw throw new Error.FAILED("Failed to read schema file: %s", e.message); + throw new DBError.FAILED("Failed to read schema file: %s", e.message); } string schema = schema_str ?? (string) schema_bytes; @@ -109,7 +109,7 @@ public class RSSuper.Database : Object { debug("Database migrated to version %d", CURRENT_VERSION); } catch (Error e) { - throw throw new Error.FAILED("Migration failed: %s".printf(e.message)); + throw new DBError.FAILED("Migration failed: %s".printf(e.message)); } } @@ -118,22 +118,22 @@ public class RSSuper.Database : Object { */ private int get_current_version() throws Error { try { - SQLite.Stmt stmt; + Sqlite.Statement stmt; int result = db.prepare_v2("SELECT COALESCE(MAX(version), 0) FROM schema_migrations;", -1, out stmt, null); - if (result != SQLite.SQLITE_OK) { - throw throw new Error.FAILED("Failed to prepare statement: %s".printf(db.errmsg())); + if (result != Sqlite.OK) { + throw new DBError.FAILED("Failed to prepare statement: %s".printf(db.errmsg())); } int version = 0; - if (stmt.step() == SQLite.SQLITE_ROW) { + if (stmt.step() == Sqlite.ROW) { version = stmt.column_int(0); } return version; } catch (Error e) { - throw throw new Error.FAILED("Failed to get migration version: %s".printf(e.message)); + throw new DBError.FAILED("Failed to get migration version: %s".printf(e.message)); } } @@ -141,23 +141,23 @@ public class RSSuper.Database : Object { * Execute a SQL statement */ public void execute(string sql) throws Error { - string errmsg = null; - int result = db.exec(sql, null, null, out errmsg); + string? errmsg; + int result = db.exec(sql, null, out errmsg); - if (result != SQLite.SQLITE_OK) { - throw throw new Error.FAILED("SQL execution failed: %s\nSQL: %s".printf(errmsg, sql)); + if (result != Sqlite.OK) { + throw new DBError.FAILED("SQL execution failed: %s\nSQL: %s".printf(errmsg, sql)); } } /** * Prepare a SQL statement */ - public SQLite.Stmt prepare(string sql) throws Error { - SQLite.Stmt stmt; + public Sqlite.Statement prepare(string sql) throws Error { + Sqlite.Statement stmt; int result = db.prepare_v2(sql, -1, out stmt, null); - if (result != SQLite.SQLITE_OK) { - throw throw new Error.FAILED("Failed to prepare statement: %s\nSQL: %s".printf(db.errmsg(), sql)); + if (result != Sqlite.OK) { + throw new DBError.FAILED("Failed to prepare statement: %s\nSQL: %s".printf(db.errmsg(), sql)); } return stmt; @@ -166,7 +166,7 @@ public class RSSuper.Database : Object { /** * Get the database connection handle */ - public SQLite.DB get_handle() { + public unowned Sqlite.Database get_handle() { return db; } diff --git a/native-route/linux/src/database/db-error.vala b/native-route/linux/src/database/db-error.vala new file mode 100644 index 0000000..69e035a --- /dev/null +++ b/native-route/linux/src/database/db-error.vala @@ -0,0 +1,24 @@ +/* + * DBError.vala + * + * Database error domain definition. + */ + +namespace RSSuper { + /** + * DBError - Database error domain + */ + public errordomain DBError { + FAILED, /** Generic database operation failed */ + NOT_FOUND, /** Record not found */ + DUPLICATE, /** Duplicate key or record */ + CORRUPTED, /** Database is corrupted */ + TIMEOUT, /** Operation timed out */ + INVALID_STATE, /** Invalid database state */ + MIGRATION_FAILED, /** Migration failed */ + CONSTRAINT_FAILED, /** Constraint violation */ + FOREIGN_KEY_FAILED, /** Foreign key constraint failed */ + UNIQUE_FAILED, /** Unique constraint failed */ + CHECK_FAILED, /** Check constraint failed */ + } +} diff --git a/native-route/linux/src/database/feed-item-store.vala b/native-route/linux/src/database/feed-item-store.vala index d388302..7c8bb3a 100644 --- a/native-route/linux/src/database/feed-item-store.vala +++ b/native-route/linux/src/database/feed-item-store.vala @@ -98,7 +98,7 @@ public class RSSuper.FeedItemStore : Object { stmt.bind_text(1, id, -1, null); - if (stmt.step() == SQLite.SQLITE_ROW) { + if (stmt.step() == Sqlite.ROW) { return row_to_item(stmt); } @@ -121,7 +121,7 @@ public class RSSuper.FeedItemStore : Object { stmt.bind_text(1, subscription_id, -1, null); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); @@ -144,7 +144,7 @@ public class RSSuper.FeedItemStore : Object { "FROM feed_items ORDER BY published DESC LIMIT 1000;" ); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); @@ -174,7 +174,7 @@ public class RSSuper.FeedItemStore : Object { stmt.bind_text(1, query, -1, null); stmt.bind_int(2, limit); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); @@ -242,7 +242,7 @@ public class RSSuper.FeedItemStore : Object { "ORDER BY published DESC LIMIT 100;" ); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); @@ -266,7 +266,7 @@ public class RSSuper.FeedItemStore : Object { "ORDER BY published DESC LIMIT 100;" ); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); @@ -326,7 +326,7 @@ public class RSSuper.FeedItemStore : Object { /** * Convert a database row to a FeedItem */ - private FeedItem? row_to_item(SQLite.Stmt stmt) { + private FeedItem? row_to_item(Sqlite.Statement stmt) { try { string categories_str = stmt.column_text(9); string[] categories = parse_categories(categories_str); diff --git a/native-route/linux/src/database/search-history-store.vala b/native-route/linux/src/database/search-history-store.vala index c701b41..7246d8e 100644 --- a/native-route/linux/src/database/search-history-store.vala +++ b/native-route/linux/src/database/search-history-store.vala @@ -74,7 +74,7 @@ public class RSSuper.SearchHistoryStore : Object { stmt.bind_int(1, limit); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var query = row_to_query(stmt); queries.append(query); } @@ -101,7 +101,7 @@ public class RSSuper.SearchHistoryStore : Object { stmt.bind_text(1, threshold, -1, null); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var query = row_to_query(stmt); queries.append(query); } @@ -148,7 +148,7 @@ public class RSSuper.SearchHistoryStore : Object { /** * Convert a database row to a SearchQuery */ - private SearchQuery row_to_query(SQLite.Stmt stmt) { + private SearchQuery row_to_query(Sqlite.Statement stmt) { string query_str = stmt.column_text(0); string? filters_json = stmt.column_text(1); string sort_str = stmt.column_text(2); diff --git a/native-route/linux/src/database/sqlite3.vapi b/native-route/linux/src/database/sqlite3.vapi index 75eb605..744ec94 100644 --- a/native-route/linux/src/database/sqlite3.vapi +++ b/native-route/linux/src/database/sqlite3.vapi @@ -7,16 +7,19 @@ namespace SQLite { [CCode (cname = "sqlite3", free_function = "sqlite3_close")] public class DB { [CCode (cname = "sqlite3_open")] - public static int open(string filename, [CCode (array_length = false)] out DB db); + public static int open(string filename, out DB db); + + [CCode (cname = "sqlite3_close")] + public int close(); [CCode (cname = "sqlite3_exec")] - public int exec(string sql, [CCode (array_length = false)] DBCallback callback = null, void* arg = null, [CCode (array_length = false)] out string errmsg = null); + public int exec(string sql, DBCallback? callback = null, void* arg = null, [CCode (array_length = false)] out string? errmsg = null); [CCode (cname = "sqlite3_errmsg")] public unowned string errmsg(); [CCode (cname = "sqlite3_prepare_v2")] - public int prepare_v2(string zSql, int nByte, [CCode (array_length = false)] out Stmt stmt, void* pzTail = null); + public int prepare_v2(string zSql, int nByte, out Stmt stmt, void* pzTail = null); } [CCode (cname = "sqlite3_stmt", free_function = "sqlite3_finalize")] @@ -47,6 +50,9 @@ namespace SQLite { [CCode (cname = "sqlite3_bind_null")] public int bind_null(int i); + + [CCode (cname = "sqlite3_finalize")] + public int finalize(); } [CCode (cname = "SQLITE_OK")] diff --git a/native-route/linux/src/database/subscription-store.vala b/native-route/linux/src/database/subscription-store.vala index e1de44c..e2f9214 100644 --- a/native-route/linux/src/database/subscription-store.vala +++ b/native-route/linux/src/database/subscription-store.vala @@ -76,7 +76,7 @@ public class RSSuper.SubscriptionStore : Object { stmt.bind_text(1, id, -1, null); - if (stmt.step() == SQLite.SQLITE_ROW) { + if (stmt.step() == Sqlite.ROW) { return row_to_subscription(stmt); } @@ -95,7 +95,7 @@ public class RSSuper.SubscriptionStore : Object { "FROM feed_subscriptions ORDER BY title;" ); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var subscription = row_to_subscription(stmt); if (subscription != null) { subscriptions.append(subscription); @@ -166,7 +166,7 @@ public class RSSuper.SubscriptionStore : Object { "FROM feed_subscriptions WHERE enabled = 1 ORDER BY title;" ); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var subscription = row_to_subscription(stmt); if (subscription != null) { subscriptions.append(subscription); @@ -194,7 +194,7 @@ public class RSSuper.SubscriptionStore : Object { stmt.bind_text(1, now_str, -1, null); - while (stmt.step() == SQLite.SQLITE_ROW) { + while (stmt.step() == Sqlite.ROW) { var subscription = row_to_subscription(stmt); if (subscription != null) { subscriptions.append(subscription); @@ -207,7 +207,7 @@ public class RSSuper.SubscriptionStore : Object { /** * Convert a database row to a FeedSubscription */ - private FeedSubscription? row_to_subscription(SQLite.Stmt stmt) { + private FeedSubscription? row_to_subscription(Sqlite.Statement stmt) { try { var subscription = new FeedSubscription.with_values( stmt.column_text(0), // id diff --git a/native-route/linux/src/tests/database-tests.vala b/native-route/linux/src/tests/database-tests.vala index dd5736a..8cacb19 100644 --- a/native-route/linux/src/tests/database-tests.vala +++ b/native-route/linux/src/tests/database-tests.vala @@ -4,285 +4,413 @@ * Unit tests for database layer. */ -public class RSSuper.DatabaseTests : TestCase { +public class RSSuper.DatabaseTests { private Database? db; private string test_db_path; - public override void setUp() { - base.setUp(); - test_db_path = "/tmp/rssuper_test_%d.db".printf((int)Time.get_current_time()); + public void run_subscription_crud() { + try { + test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix()); + db = new Database(test_db_path); + } catch (DBError e) { + warning("Failed to create test database: %s", e.message); + return; + } try { - db = new Database(test_db_path); - } catch (DatabaseError e) { - warn("Failed to create test database: %s", e.message); + var store = new SubscriptionStore(db); + + // Create test subscription + var subscription = new FeedSubscription.with_values( + "sub_1", + "https://example.com/feed.xml", + "Example Feed", + 60, + "Technology", + true, + "2024-01-01T00:00:00Z", + "2024-01-01T00:00:00Z" + ); + + // Test add + store.add(subscription); + var retrieved = store.get_by_id("sub_1"); + if (retrieved == null) { + printerr("FAIL: Expected subscription to exist after add\n"); + return; + } + + // Test get + if (retrieved.title != "Example Feed") { + printerr("FAIL: Expected title 'Example Feed', got '%s'\n", retrieved.title); + return; + } + if (retrieved.url != "https://example.com/feed.xml") { + printerr("FAIL: Expected url 'https://example.com/feed.xml', got '%s'\n", retrieved.url); + return; + } + + // Test update + retrieved.title = "Updated Feed"; + store.update(retrieved); + var updated = store.get_by_id("sub_1"); + if (updated.title != "Updated Feed") { + printerr("FAIL: Expected title 'Updated Feed', got '%s'\n", updated.title); + return; + } + + // Test delete + store.remove_subscription("sub_1"); + var deleted = store.get_by_id("sub_1"); + if (deleted != null) { + printerr("FAIL: Expected subscription to be deleted\n"); + return; + } + + print("PASS: test_subscription_crud\n"); + } finally { + cleanup(); } } - public override void tearDown() { - base.tearDown(); + public void run_subscription_list() { + try { + test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix()); + db = new Database(test_db_path); + } catch (DBError e) { + warning("Failed to create test database: %s", e.message); + return; + } + try { + var store = new SubscriptionStore(db); + + // Add multiple subscriptions + var sub1 = new FeedSubscription.with_values("sub_1", "https://feed1.com", "Feed 1"); + var sub2 = new FeedSubscription.with_values("sub_2", "https://feed2.com", "Feed 2"); + var sub3 = new FeedSubscription.with_values("sub_3", "https://feed3.com", "Feed 3", 60, null, false); + + store.add(sub1); + store.add(sub2); + store.add(sub3); + + // Test get_all + var all = store.get_all(); + if (all.length != 3) { + printerr("FAIL: Expected 3 subscriptions, got %d\n", all.length); + return; + } + + // Test get_enabled + var enabled = store.get_enabled(); + if (enabled.length != 2) { + printerr("FAIL: Expected 2 enabled subscriptions, got %d\n", enabled.length); + return; + } + + print("PASS: test_subscription_list\n"); + } finally { + cleanup(); + } + } + + public void run_feed_item_crud() { + try { + test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix()); + db = new Database(test_db_path); + } catch (DBError e) { + warning("Failed to create test database: %s", e.message); + return; + } + + try { + var sub_store = new SubscriptionStore(db); + var item_store = new FeedItemStore(db); + + // Create subscription first + var subscription = new FeedSubscription.with_values( + "sub_1", "https://example.com/feed.xml", "Example Feed" + ); + sub_store.add(subscription); + + // Create test item + var item = new FeedItem.with_values( + "item_1", + "Test Article", + "https://example.com/article", + "This is a test description", + "Full content of the article", + "John Doe", + "2024-01-01T12:00:00Z", + "2024-01-01T12:00:00Z", + {"Technology", "News"}, + null, null, null, null, + "Example Feed" + ); + + // Test add + item_store.add(item); + var retrieved = item_store.get_by_id("item_1"); + if (retrieved == null) { + printerr("FAIL: Expected item to exist after add\n"); + return; + } + if (retrieved.title != "Test Article") { + printerr("FAIL: Expected title 'Test Article', got '%s'\n", retrieved.title); + return; + } + + // Test get by subscription + var items = item_store.get_by_subscription("sub_1"); + if (items.length != 1) { + printerr("FAIL: Expected 1 item, got %d\n", items.length); + return; + } + + // Test mark as read + item_store.mark_as_read("item_1"); + + // Test delete + item_store.delete("item_1"); + var deleted = item_store.get_by_id("item_1"); + if (deleted != null) { + printerr("FAIL: Expected item to be deleted\n"); + return; + } + + print("PASS: test_feed_item_crud\n"); + } finally { + cleanup(); + } + } + + public void run_feed_item_batch() { + try { + test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix()); + db = new Database(test_db_path); + } catch (DBError e) { + warning("Failed to create test database: %s", e.message); + return; + } + + try { + var sub_store = new SubscriptionStore(db); + var item_store = new FeedItemStore(db); + + // Create subscription + var subscription = new FeedSubscription.with_values( + "sub_1", "https://example.com/feed.xml", "Example Feed" + ); + sub_store.add(subscription); + + // Create multiple items + var items = new FeedItem[5]; + for (var i = 0; i < 5; i++) { + items[i] = new FeedItem.with_values( + "item_%d".printf(i), + "Article %d".printf(i), + "https://example.com/article%d".printf(i), + "Description %d".printf(i), + null, + "Author %d".printf(i), + "2024-01-%02dT12:00:00Z".printf(i + 1), + null, + null, + null, null, null, null, + "Example Feed" + ); + } + + // Test batch insert + item_store.add_batch(items); + + var all = item_store.get_by_subscription("sub_1"); + if (all.length != 5) { + printerr("FAIL: Expected 5 items, got %d\n", all.length); + return; + } + + print("PASS: test_feed_item_batch\n"); + } finally { + cleanup(); + } + } + + public void run_search_history() { + try { + test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix()); + db = new Database(test_db_path); + } catch (DBError e) { + warning("Failed to create test database: %s", e.message); + return; + } + + try { + var store = new SearchHistoryStore(db); + + // Create test queries + var query1 = SearchQuery("test query", 1, 20, null, SearchSortOption.RELEVANCE); + var query2 = SearchQuery("another search", 1, 10, null, SearchSortOption.DATE_DESC); + + // Test record + store.record_search(query1, 15); + store.record_search(query2, 8); + + // Test get_history + var history = store.get_history(); + if (history.length != 2) { + printerr("FAIL: Expected 2 history entries, got %d\n", history.length); + return; + } + if (history[0].query != "another search") { + printerr("FAIL: Expected 'another search', got '%s'\n", history[0].query); + return; + } + + // Test get_recent + var recent = store.get_recent(); + if (recent.length != 2) { + printerr("FAIL: Expected 2 recent entries, got %d\n", recent.length); + return; + } + + print("PASS: test_search_history\n"); + } finally { + cleanup(); + } + } + + public void run_fts_search() { + try { + test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix()); + db = new Database(test_db_path); + } catch (DBError e) { + warning("Failed to create test database: %s", e.message); + return; + } + + try { + var sub_store = new SubscriptionStore(db); + var item_store = new FeedItemStore(db); + + // Create subscription + var subscription = new FeedSubscription.with_values( + "sub_1", "https://example.com/feed.xml", "Example Feed" + ); + sub_store.add(subscription); + + // Add items with searchable content + var item1 = new FeedItem.with_values( + "item_1", + "Swift Programming Guide", + "https://example.com/swift", + "Learn Swift programming language basics", + "A comprehensive guide to Swift", + "Apple Developer", + "2024-01-01T12:00:00Z", + null, + null, + null, null, null, null, + "Example Feed" + ); + + var item2 = new FeedItem.with_values( + "item_2", + "Python for Data Science", + "https://example.com/python", + "Data analysis with Python and pandas", + "Complete Python data science tutorial", + "Data Team", + "2024-01-02T12:00:00Z", + null, + null, + null, null, null, null, + "Example Feed" + ); + + item_store.add(item1); + item_store.add(item2); + + // Test FTS search + var results = item_store.search("swift"); + if (results.length != 1) { + printerr("FAIL: Expected 1 result for 'swift', got %d\n", results.length); + return; + } + if (results[0].title != "Swift Programming Guide") { + printerr("FAIL: Expected 'Swift Programming Guide', got '%s'\n", results[0].title); + return; + } + + results = item_store.search("python"); + if (results.length != 1) { + printerr("FAIL: Expected 1 result for 'python', got %d\n", results.length); + return; + } + if (results[0].title != "Python for Data Science") { + printerr("FAIL: Expected 'Python for Data Science', got '%s'\n", results[0].title); + return; + } + + print("PASS: test_fts_search\n"); + } finally { + cleanup(); + } + } + + private void cleanup() { if (db != null) { db.close(); db = null; } // Clean up test database - var file = File.new_for_path(test_db_path); - if (file.query_exists()) { - try { - file.delete(); - } catch (DatabaseError e) { - warn("Failed to delete test database: %s", e.message); + if (test_db_path != null && test_db_path.length > 0) { + var file = File.new_for_path(test_db_path); + if (file.query_exists()) { + try { + file.delete(); + } catch (Error e) { + warning("Failed to delete test database: %s", e.message); + } + } + + // Clean up WAL file + var wal_file = File.new_for_path(test_db_path + "-wal"); + if (wal_file.query_exists()) { + try { + wal_file.delete(); + } catch (Error e) { + warning("Failed to delete WAL file: %s", e.message); + } } } - - // Clean up WAL file - var wal_file = File.new_for_path(test_db_path + "-wal"); - if (wal_file.query_exists()) { - try { - wal_file.delete(); - } catch (DatabaseError e) { - warn("Failed to delete WAL file: %s", e.message); - } - } - } - - public void test_subscription_crud() { - if (db == null) return; - - var store = new SubscriptionStore(db); - - // Create test subscription - var subscription = new FeedSubscription.with_values( - "sub_1", - "https://example.com/feed.xml", - "Example Feed", - 60, - "Technology", - true, - "2024-01-01T00:00:00Z", - "2024-01-01T00:00:00Z" - ); - - // Test add - store.add(subscription); - assert_not_null(store.get_by_id("sub_1")); - - // Test get - var retrieved = store.get_by_id("sub_1"); - assert_not_null(retrieved); - assert_equal("Example Feed", retrieved.title); - assert_equal("https://example.com/feed.xml", retrieved.url); - - // Test update - retrieved.title = "Updated Feed"; - store.update(retrieved); - var updated = store.get_by_id("sub_1"); - assert_equal("Updated Feed", updated.title); - - // Test delete - store.delete("sub_1"); - var deleted = store.get_by_id("sub_1"); - assert_null(deleted); - } - - public void test_subscription_list() { - if (db == null) return; - - var store = new SubscriptionStore(db); - - // Add multiple subscriptions - var sub1 = new FeedSubscription.with_values("sub_1", "https://feed1.com", "Feed 1"); - var sub2 = new FeedSubscription.with_values("sub_2", "https://feed2.com", "Feed 2"); - var sub3 = new FeedSubscription.with_values("sub_3", "https://feed3.com", "Feed 3", 60, null, false); - - store.add(sub1); - store.add(sub2); - store.add(sub3); - - // Test get_all - var all = store.get_all(); - assert_equal(3, all.length); - - // Test get_enabled - var enabled = store.get_enabled(); - assert_equal(2, enabled.length); - } - - public void test_feed_item_crud() { - if (db == null) return; - - var sub_store = new SubscriptionStore(db); - var item_store = new FeedItemStore(db); - - // Create subscription first - var subscription = new FeedSubscription.with_values( - "sub_1", "https://example.com/feed.xml", "Example Feed" - ); - sub_store.add(subscription); - - // Create test item - var item = new FeedItem.with_values( - "item_1", - "Test Article", - "https://example.com/article", - "This is a test description", - "Full content of the article", - "John Doe", - "2024-01-01T12:00:00Z", - "2024-01-01T12:00:00Z", - {"Technology", "News"}, - null, null, null, null, - "Example Feed" - ); - - // Test add - item_store.add(item); - var retrieved = item_store.get_by_id("item_1"); - assert_not_null(retrieved); - assert_equal("Test Article", retrieved.title); - - // Test get by subscription - var items = item_store.get_by_subscription("sub_1"); - assert_equal(1, items.length); - - // Test mark as read - item_store.mark_as_read("item_1"); - - // Test delete - item_store.delete("item_1"); - var deleted = item_store.get_by_id("item_1"); - assert_null(deleted); - } - - public void test_feed_item_batch() { - if (db == null) return; - - var sub_store = new SubscriptionStore(db); - var item_store = new FeedItemStore(db); - - // Create subscription - var subscription = new FeedSubscription.with_values( - "sub_1", "https://example.com/feed.xml", "Example Feed" - ); - sub_store.add(subscription); - - // Create multiple items - var items = new FeedItem[5]; - for (var i = 0; i < 5; i++) { - items[i] = new FeedItem.with_values( - "item_%d".printf(i), - "Article %d".printf(i), - "https://example.com/article%d".printf(i), - "Description %d".printf(i), - null, - "Author %d".printf(i), - "2024-01-%02dT12:00:00Z".printf(i + 1), - null, - null, - null, null, null, null, - "Example Feed" - ); - } - - // Test batch insert - item_store.add_batch(items); - - var all = item_store.get_by_subscription("sub_1"); - assert_equal(5, all.length); - } - - public void test_search_history() { - if (db == null) return; - - var store = new SearchHistoryStore(db); - - // Create test queries - var query1 = SearchQuery("test query", 1, 20, null, SearchSortOption.RELEVANCE); - var query2 = SearchQuery("another search", 1, 10, null, SearchSortOption.DATE_DESC); - - // Test record - store.record_search(query1, 15); - store.record_search(query2, 8); - - // Test get_history - var history = store.get_history(); - assert_equal(2, history.length); - assert_equal("another search", history[0].query); // Most recent first - - // Test get_recent - var recent = store.get_recent(); - assert_equal(2, recent.length); - } - - public void test_fts_search() { - if (db == null) return; - - var sub_store = new SubscriptionStore(db); - var item_store = new FeedItemStore(db); - - // Create subscription - var subscription = new FeedSubscription.with_values( - "sub_1", "https://example.com/feed.xml", "Example Feed" - ); - sub_store.add(subscription); - - // Add items with searchable content - var item1 = new FeedItem.with_values( - "item_1", - "Swift Programming Guide", - "https://example.com/swift", - "Learn Swift programming language basics", - "A comprehensive guide to Swift", - "Apple Developer", - "2024-01-01T12:00:00Z", - null, - null, - null, null, null, null, - "Example Feed" - ); - - var item2 = new FeedItem.with_values( - "item_2", - "Python for Data Science", - "https://example.com/python", - "Data analysis with Python and pandas", - "Complete Python data science tutorial", - "Data Team", - "2024-01-02T12:00:00Z", - null, - null, - null, null, null, null, - "Example Feed" - ); - - item_store.add(item1); - item_store.add(item2); - - // Test FTS search - var results = item_store.search("swift"); - assert_equal(1, results.length); - assert_equal("Swift Programming Guide", results[0].title); - - results = item_store.search("python"); - assert_equal(1, results.length); - assert_equal("Python for Data Science", results[0].title); } public static int main(string[] args) { - Test.init(ref args); + print("Running database tests...\n"); - var suite = Test.create_suite(); + var tests = new DatabaseTests(); - var test_case = new DatabaseTests(); - Test.add_func("/database/subscription_crud", test_case.test_subscription_crud); - Test.add_func("/database/subscription_list", test_case.test_subscription_list); - Test.add_func("/database/feed_item_crud", test_case.test_feed_item_crud); - Test.add_func("/database/feed_item_batch", test_case.test_feed_item_batch); - Test.add_func("/database/search_history", test_case.test_search_history); - Test.add_func("/database/fts_search", test_case.test_fts_search); + print("\n=== Running subscription CRUD tests ==="); + tests.run_subscription_crud(); - return Test.run(); + print("\n=== Running subscription list tests ==="); + tests.run_subscription_list(); + + print("\n=== Running feed item CRUD tests ==="); + tests.run_feed_item_crud(); + + print("\n=== Running feed item batch tests ==="); + tests.run_feed_item_batch(); + + print("\n=== Running search history tests ==="); + tests.run_search_history(); + + print("\n=== Running FTS search tests ==="); + tests.run_fts_search(); + + print("\nAll tests completed!\n"); + return 0; } }