diff --git a/docs/profanity.1 b/docs/profanity.1 index b00e30c7..2ab56c92 100755 --- a/docs/profanity.1 +++ b/docs/profanity.1 @@ -234,6 +234,10 @@ to copy messages from SQLite to flat-file format (merge with existing data), or to copy from flat-file to SQLite. Both operations skip duplicates. Omit the JID to process all contacts. +.PP +Use +.B /history switch sqlite|flatfile +to change the active database backend at runtime without reconnecting. .SH BUGS Bugs can either be reported by raising an issue at the Github issue tracker: .br diff --git a/src/database.h b/src/database.h index 7a9e48b2..647fedbd 100644 --- a/src/database.h +++ b/src/database.h @@ -88,6 +88,10 @@ extern db_backend_t* active_db_backend; #ifdef HAVE_SQLITE db_backend_t* db_backend_sqlite(void); GSList* db_sqlite_list_contacts(void); +GSList* db_sqlite_get_all_chat(const gchar* const contact_barejid); +void db_sqlite_begin_transaction(void); +void db_sqlite_end_transaction(void); +void db_sqlite_rollback_transaction(void); #endif db_backend_t* db_backend_flatfile(void); diff --git a/src/database_export.c b/src/database_export.c index 03b561cb..e16660c1 100644 --- a/src/database_export.c +++ b/src/database_export.c @@ -64,8 +64,8 @@ _make_dedup_key(const char* stanza_id, const char* timestamp, const char* from_j if (stanza_id && strlen(stanza_id) > 0) return g_strdup(stanza_id); - // Fallback: simple composite key - auto_gchar gchar* raw = g_strdup_printf("%s|%s|%.64s", + // Fallback: composite key from timestamp + sender + body prefix + auto_gchar gchar* raw = g_strdup_printf("%s|%s|%.256s", timestamp ? timestamp : "", from_jid ? from_jid : "", body ? body : ""); @@ -118,13 +118,13 @@ _ff_list_contacts(void) if (g_file_test(full_path, G_FILE_TEST_IS_REGULAR)) { char* jid = _dir_to_jid(entry); if (jid) { - contacts = g_slist_append(contacts, jid); + contacts = g_slist_prepend(contacts, jid); } } } g_dir_close(dir); - return contacts; + return g_slist_reverse(contacts); } // ========================================================================= @@ -161,12 +161,31 @@ _ff_read_all_lines(const char* contact_barejid) ff_parsed_line_t* pl = ff_parse_line(buf); free(buf); if (pl) { - lines = g_slist_append(lines, pl); + lines = g_slist_prepend(lines, pl); } } fclose(fp); - return lines; + return g_slist_reverse(lines); +} + +// ========================================================================= +// Comparator for sorting ff_parsed_line_t by timestamp (ISO8601 is lexicographic) +// ========================================================================= + +static gint +_compare_parsed_lines_by_timestamp(gconstpointer a, gconstpointer b) +{ + const ff_parsed_line_t* la = a; + const ff_parsed_line_t* lb = b; + int cmp = g_strcmp0(la->timestamp_str, lb->timestamp_str); + if (cmp != 0) + return cmp; + // Secondary key: stanza_id (stabilises sort for same-second messages) + cmp = g_strcmp0(la->stanza_id, lb->stanza_id); + if (cmp != 0) + return cmp; + return g_strcmp0(la->from_jid, lb->from_jid); } // ========================================================================= @@ -235,48 +254,8 @@ log_database_export_to_flatfile(const gchar* const contact_jid) } int existing_count = g_slist_length(existing); - // 2. Query SQLite for this contact — get ALL messages ordered by timestamp - // We read all messages by paginating through the existing get_previous_chat - // But it limits to MESSAGES_TO_RETRIEVE... We need a direct SQL query. - // Use the same approach as verify — iterate with raw SQL. - - // For now, collect all messages into a list via repeated calls - // Actually, let's use a simpler approach: read all from flatfile existing data, - // then query SQLite for everything, merge, write back. - - // Read all SQLite messages for this contact - GSList* sqlite_lines = NULL; - // Use the backend's get_previous_chat with a wide time range - // This is limited to MESSAGES_TO_RETRIEVE, so we need to paginate - gboolean has_more = TRUE; - gchar* start_time = NULL; - - while (has_more) { - GSList* batch = NULL; - db_history_result_t res = sqlite_be->get_previous_chat( - contact, start_time, NULL, TRUE, FALSE, &batch); - - if (res != DB_RESPONSE_SUCCESS || !batch) { - has_more = FALSE; - break; - } - - int batch_size = g_slist_length(batch); - - // Get the last timestamp for the next query - ProfMessage* last_msg = g_slist_last(batch)->data; - if (last_msg && last_msg->timestamp) { - g_free(start_time); - start_time = g_date_time_format_iso8601(last_msg->timestamp); - } - - sqlite_lines = g_slist_concat(sqlite_lines, batch); - - if (batch_size < MESSAGES_TO_RETRIEVE) { - has_more = FALSE; - } - } - g_free(start_time); + // 2. Query SQLite for this contact — get ALL messages via direct SQL + GSList* sqlite_lines = db_sqlite_get_all_chat(contact); if (!sqlite_lines) { log_debug("export: no SQLite messages for %s", contact); @@ -317,16 +296,31 @@ log_database_export_to_flatfile(const gchar* const contact_jid) int contact_exported = 0; int contact_skipped = 0; - // Write existing flatfile lines first (preserve originals) + // Collect ALL lines into a merged list for sorted output + GSList* merged = NULL; + + // Add existing flatfile lines (mark as already seen in dedup) for (GSList* l = existing; l; l = l->next) { ff_parsed_line_t* pl = l->data; - ff_write_line(fp, pl->timestamp_str, - pl->type, pl->enc, - pl->stanza_id, pl->archive_id, pl->replace_id, - pl->from_jid, pl->from_resource, pl->message); + // Create a shallow copy for the merged list (originals freed separately) + ff_parsed_line_t* copy = g_malloc0(sizeof(ff_parsed_line_t)); + copy->timestamp_str = g_strdup(pl->timestamp_str); + copy->timestamp = pl->timestamp ? g_date_time_ref(pl->timestamp) : NULL; + copy->type = g_strdup(pl->type); + copy->enc = g_strdup(pl->enc); + copy->stanza_id = g_strdup(pl->stanza_id); + copy->archive_id = g_strdup(pl->archive_id); + copy->replace_id = g_strdup(pl->replace_id); + copy->from_jid = g_strdup(pl->from_jid); + copy->from_resource = g_strdup(pl->from_resource); + copy->to_jid = g_strdup(pl->to_jid); + copy->to_resource = g_strdup(pl->to_resource); + copy->marked_read = pl->marked_read; + copy->message = g_strdup(pl->message); + merged = g_slist_prepend(merged, copy); } - // Write SQLite messages that aren't already in the flatfile + // Add SQLite messages that aren't already in the flatfile for (GSList* l = sqlite_lines; l; l = l->next) { ProfMessage* msg = l->data; if (!msg || !msg->timestamp) @@ -345,17 +339,47 @@ log_database_export_to_flatfile(const gchar* const contact_jid) } g_hash_table_add(seen_keys, key); - const char* type_str = ff_get_message_type_str(msg->type); - const char* enc_str = ff_get_message_enc_str(msg->enc); - const char* from_res = msg->from_jid ? msg->from_jid->resourcepart : NULL; - - ff_write_line(fp, ts, type_str, enc_str, - sid, msg->stanzaid, NULL, - from_jid, from_res, body); + ff_parsed_line_t* pl = g_malloc0(sizeof(ff_parsed_line_t)); + pl->timestamp_str = g_strdup(ts); + pl->timestamp = g_date_time_ref(msg->timestamp); + pl->type = g_strdup(ff_get_message_type_str(msg->type)); + pl->enc = g_strdup(ff_get_message_enc_str(msg->enc)); + pl->stanza_id = g_strdup(sid); + pl->archive_id = msg->stanzaid ? g_strdup(msg->stanzaid) : NULL; + pl->replace_id = NULL; + pl->from_jid = g_strdup(from_jid); + pl->from_resource = msg->from_jid ? g_strdup(msg->from_jid->resourcepart) : NULL; + pl->to_jid = msg->to_jid ? g_strdup(msg->to_jid->barejid) : NULL; + pl->to_resource = msg->to_jid ? g_strdup(msg->to_jid->resourcepart) : NULL; + pl->marked_read = msg->marked_read; + pl->message = g_strdup(body); + merged = g_slist_prepend(merged, pl); contact_exported++; } + // Sort merged list by timestamp (ISO8601 is lexicographically sortable) + merged = g_slist_sort(merged, _compare_parsed_lines_by_timestamp); + + // Write all lines sorted + int written = 0; + for (GSList* l = merged; l; l = l->next) { + ff_parsed_line_t* pl = l->data; + ff_write_line(fp, pl->timestamp_str, + pl->type, pl->enc, + pl->stanza_id, pl->archive_id, pl->replace_id, + pl->from_jid, pl->from_resource, + pl->to_jid, pl->to_resource, pl->marked_read, + pl->message); + written++; + if (written % 500 == 0) { + cons_show(" ... %s: %d/%d written so far", contact, written, (int)g_slist_length(merged)); + } + } + + g_slist_free_full(merged, (GDestroyNotify)ff_parsed_line_free); + fflush(fp); + fsync(fd); // ensure data is on disk before atomic rename fclose(fp); // also releases flock // Atomic rename @@ -430,48 +454,26 @@ log_database_import_from_flatfile(const gchar* const contact_jid) // 2. Build dedup set from SQLite (existing stanza_ids + fallback keys) GHashTable* seen_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); - // Read all SQLite messages for this contact - gboolean has_more = TRUE; - gchar* start_time = NULL; - - while (has_more) { - GSList* batch = NULL; - db_history_result_t res = sqlite_be->get_previous_chat( - contact, start_time, NULL, TRUE, FALSE, &batch); - - if (res != DB_RESPONSE_SUCCESS || !batch) { - has_more = FALSE; - break; - } - - int batch_size = g_slist_length(batch); - - ProfMessage* last_msg = g_slist_last(batch)->data; - if (last_msg && last_msg->timestamp) { - g_free(start_time); - start_time = g_date_time_format_iso8601(last_msg->timestamp); - } - - for (GSList* l = batch; l; l = l->next) { - ProfMessage* msg = l->data; - if (!msg) - continue; - auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup(""); - char* key = _make_dedup_key(msg->id, ts, msg->from_jid ? msg->from_jid->barejid : "", msg->plain); - g_hash_table_add(seen_keys, key); - } - - g_slist_free_full(batch, (GDestroyNotify)message_free); - - if (batch_size < MESSAGES_TO_RETRIEVE) { - has_more = FALSE; - } + // Read all SQLite messages for this contact via direct SQL query + GSList* existing_msgs = db_sqlite_get_all_chat(contact); + for (GSList* l = existing_msgs; l; l = l->next) { + ProfMessage* msg = l->data; + if (!msg) + continue; + auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup(""); + char* key = _make_dedup_key(msg->id, ts, msg->from_jid ? msg->from_jid->barejid : "", msg->plain); + g_hash_table_add(seen_keys, key); } - g_free(start_time); + g_slist_free_full(existing_msgs, (GDestroyNotify)message_free); - // 3. For each flatfile line not in seen_keys, insert via add_incoming/add_outgoing + // 3. For each flatfile line not in seen_keys, insert via add_incoming + // Wrap in a transaction for atomicity and performance int contact_imported = 0; int contact_skipped = 0; + int total_lines = g_slist_length(ff_lines); + + db_sqlite_begin_transaction(); + gboolean import_ok = TRUE; for (GSList* l = ff_lines; l; l = l->next) { ff_parsed_line_t* pl = l->data; @@ -504,15 +506,34 @@ log_database_import_from_flatfile(const gchar* const contact_jid) if (is_outgoing) { msg->from_jid = jid_create(myjid->barejid); - msg->to_jid = jid_create(contact); + if (pl->to_jid) { + msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource); + } else { + msg->to_jid = jid_create(contact); + } } else { msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource); - msg->to_jid = jid_create(myjid->barejid); + if (pl->to_jid) { + msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource); + } else { + msg->to_jid = jid_create(myjid->barejid); + } } sqlite_be->add_incoming(msg); message_free(msg); contact_imported++; + + if (contact_imported % 500 == 0) { + cons_show(" ... %s: %d/%d imported so far", contact, contact_imported, total_lines); + } + } + + if (import_ok) { + db_sqlite_end_transaction(); + } else { + db_sqlite_rollback_transaction(); + cons_show_error("Import of %s failed — transaction rolled back.", contact); } cons_show("Imported %s: %d new, %d skipped (already in SQLite)", diff --git a/src/database_flatfile.c b/src/database_flatfile.c index b8e10043..f0875d90 100644 --- a/src/database_flatfile.c +++ b/src/database_flatfile.c @@ -320,8 +320,8 @@ _ff_get_state(const char* contact_barejid) static void _ff_add_message(const char* type, const char* stanza_id, const char* archive_id, const char* replace_id, const char* from_barejid, const char* from_resource, - const char* to_barejid, const char* message_text, GDateTime* timestamp, - prof_enc_t enc) + const char* to_barejid, const char* to_resource, const char* message_text, + GDateTime* timestamp, prof_enc_t enc) { auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG); @@ -408,7 +408,9 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id, ff_write_line(fp, date_fmt, type, ff_get_message_enc_str(enc), stanza_id, archive_id, replace_id, - from_barejid, from_resource, effective_msg); + from_barejid, from_resource, + to_barejid, to_resource, -1, + effective_msg); fflush(fp); // fclose also releases the flock @@ -586,7 +588,7 @@ _flatfile_add_incoming(ProfMessage* message) _ff_add_message(type, message->id, message->stanzaid, message->replace_id, message->from_jid->barejid, message->from_jid->resourcepart, - to_jid->barejid, message->plain, + to_jid->barejid, to_jid->resourcepart, message->plain, message->timestamp, message->enc); } @@ -597,7 +599,7 @@ _flatfile_add_outgoing_chat(const char* const id, const char* const barejid, const Jid* myjid = connection_get_jid(); _ff_add_message("chat", id, NULL, replace_id, myjid ? myjid->barejid : "me", myjid ? myjid->resourcepart : NULL, - barejid, message, NULL, enc); + barejid, NULL, message, NULL, enc); } static void @@ -607,7 +609,7 @@ _flatfile_add_outgoing_muc(const char* const id, const char* const barejid, const Jid* myjid = connection_get_jid(); _ff_add_message("muc", id, NULL, replace_id, myjid ? myjid->barejid : "me", myjid ? myjid->resourcepart : NULL, - barejid, message, NULL, enc); + barejid, NULL, message, NULL, enc); } static void @@ -617,7 +619,7 @@ _flatfile_add_outgoing_muc_pm(const char* const id, const char* const barejid, const Jid* myjid = connection_get_jid(); _ff_add_message("mucpm", id, NULL, replace_id, myjid ? myjid->barejid : "me", myjid ? myjid->resourcepart : NULL, - barejid, message, NULL, enc); + barejid, NULL, message, NULL, enc); } // ========================================================================= diff --git a/src/database_flatfile.h b/src/database_flatfile.h index 49c0671e..720e7475 100644 --- a/src/database_flatfile.h +++ b/src/database_flatfile.h @@ -60,6 +60,9 @@ typedef struct char* replace_id; char* from_jid; char* from_resource; + char* to_jid; + char* to_resource; + int marked_read; // -1 = unset (NULL in DB), 0 = unread, 1 = read char* message; off_t file_offset; } ff_parsed_line_t; @@ -125,7 +128,9 @@ char* ff_unescape_meta_value(const char* val); char* ff_readline(FILE* fp, gboolean* truncated); void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc, const char* stanza_id, const char* archive_id, const char* replace_id, - const char* from_jid, const char* from_resource, const char* message_text); + const char* from_jid, const char* from_resource, + const char* to_jid, const char* to_resource, int marked_read, + const char* message_text); // --- Parser helpers --- diff --git a/src/database_flatfile_parser.c b/src/database_flatfile_parser.c index 56d6b13e..43fac657 100644 --- a/src/database_flatfile_parser.c +++ b/src/database_flatfile_parser.c @@ -402,14 +402,16 @@ ff_readline(FILE* fp, gboolean* truncated) void ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc, const char* stanza_id, const char* archive_id, const char* replace_id, - const char* from_jid, const char* from_resource, const char* message_text) + const char* from_jid, const char* from_resource, + const char* to_jid, const char* to_resource, int marked_read, + const char* message_text) { // Escape metadata values from remote peers auto_gchar gchar* safe_sid = ff_escape_meta_value(stanza_id); auto_gchar gchar* safe_aid = ff_escape_meta_value(archive_id); auto_gchar gchar* safe_rid = ff_escape_meta_value(replace_id); - // Build metadata section: [type|enc|id:...|aid:...|corrects:...] + // Build metadata section: [type|enc|id:...|aid:...|corrects:...|to:...|to_res:...|read:...] GString* meta = g_string_new("["); g_string_append(meta, type ? type : "chat"); g_string_append_c(meta, '|'); @@ -423,6 +425,17 @@ ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* enc if (safe_rid) { g_string_append_printf(meta, "|corrects:%s", safe_rid); } + if (to_jid && strlen(to_jid) > 0) { + auto_gchar gchar* safe_to = ff_escape_meta_value(to_jid); + g_string_append_printf(meta, "|to:%s", safe_to); + } + if (to_resource && strlen(to_resource) > 0) { + auto_gchar gchar* safe_tores = ff_escape_meta_value(to_resource); + g_string_append_printf(meta, "|to_res:%s", safe_tores); + } + if (marked_read >= 0) { + g_string_append_printf(meta, "|read:%d", marked_read ? 1 : 0); + } g_string_append_c(meta, ']'); // Build sender — escape ": " in the resource part to prevent @@ -573,6 +586,8 @@ ff_parsed_line_free(ff_parsed_line_t* pl) g_free(pl->replace_id); g_free(pl->from_jid); g_free(pl->from_resource); + g_free(pl->to_jid); + g_free(pl->to_resource); g_free(pl->message); g_free(pl); } @@ -619,6 +634,7 @@ ff_parse_line(const char* line) ff_parsed_line_t* result = g_malloc0(sizeof(ff_parsed_line_t)); result->file_offset = -1; + result->marked_read = -1; // unset by default // Parse timestamp — everything up to first space followed by '[' char* bracket_start = strchr(work, '['); @@ -648,6 +664,12 @@ ff_parse_line(const char* line) result->archive_id = ff_unescape_meta_value(parts[i] + 4); } else if (g_str_has_prefix(parts[i], "corrects:")) { result->replace_id = ff_unescape_meta_value(parts[i] + 9); + } else if (g_str_has_prefix(parts[i], "to:")) { + result->to_jid = ff_unescape_meta_value(parts[i] + 3); + } else if (g_str_has_prefix(parts[i], "to_res:")) { + result->to_resource = ff_unescape_meta_value(parts[i] + 7); + } else if (g_str_has_prefix(parts[i], "read:")) { + result->marked_read = atoi(parts[i] + 5) ? 1 : 0; } } g_strfreev(parts); @@ -747,6 +769,9 @@ ff_parsed_to_profmessage(ff_parsed_line_t* pl) ProfMessage* msg = message_init(); msg->id = pl->stanza_id ? g_strdup(pl->stanza_id) : NULL; msg->from_jid = jid_create_from_bare_and_resource(pl->from_jid, pl->from_resource); + if (pl->to_jid) { + msg->to_jid = jid_create_from_bare_and_resource(pl->to_jid, pl->to_resource); + } msg->plain = g_strdup(pl->message ? pl->message : ""); msg->timestamp = g_date_time_ref(pl->timestamp); msg->type = ff_get_message_type_type(pl->type); diff --git a/src/database_sqlite.c b/src/database_sqlite.c index 761f8515..75af6b03 100644 --- a/src/database_sqlite.c +++ b/src/database_sqlite.c @@ -818,6 +818,111 @@ db_backend_sqlite(void) return &sqlite_backend; } +void +db_sqlite_begin_transaction(void) +{ + if (!g_chatlog_database) + return; + char* err_msg = NULL; + if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "BEGIN TRANSACTION", NULL, 0, &err_msg)) { + if (err_msg) { + log_error("SQLite BEGIN TRANSACTION failed: %s", err_msg); + sqlite3_free(err_msg); + } + } +} + +void +db_sqlite_end_transaction(void) +{ + if (!g_chatlog_database) + return; + char* err_msg = NULL; + if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "END TRANSACTION", NULL, 0, &err_msg)) { + if (err_msg) { + log_error("SQLite END TRANSACTION failed: %s", err_msg); + sqlite3_free(err_msg); + } + } +} + +void +db_sqlite_rollback_transaction(void) +{ + if (!g_chatlog_database) + return; + char* err_msg = NULL; + if (SQLITE_OK != sqlite3_exec(g_chatlog_database, "ROLLBACK", NULL, 0, &err_msg)) { + if (err_msg) { + log_error("SQLite ROLLBACK failed: %s", err_msg); + sqlite3_free(err_msg); + } + } +} + +GSList* +db_sqlite_get_all_chat(const gchar* const contact_barejid) +{ + GSList* result = NULL; + + if (!g_chatlog_database) + return NULL; + + const Jid* myjid = connection_get_jid(); + if (!myjid || !myjid->barejid) + return NULL; + + sqlite3_stmt* stmt = NULL; + auto_sqlite char* query = sqlite3_mprintf( + "SELECT COALESCE(B.`message`, A.`message`) AS message, " + "A.`timestamp`, A.`from_jid`, A.`from_resource`, A.`to_jid`, A.`to_resource`, " + "A.`type`, A.`encryption`, A.`stanza_id`, A.`archive_id`, A.`marked_read` " + "FROM `ChatLogs` AS A " + "LEFT JOIN `ChatLogs` AS B ON (A.`replaced_by_db_id` = B.`id` AND A.`from_jid` = B.`from_jid`) " + "WHERE (A.`replaces_db_id` IS NULL) " + "AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) " + "ORDER BY A.`timestamp` ASC", + contact_barejid, myjid->barejid, myjid->barejid, contact_barejid); + + if (!query) + return NULL; + + if (!_db_prepare_ctx(query, &stmt, "db_sqlite_get_all_chat()")) + return NULL; + + while (sqlite3_step(stmt) == SQLITE_ROW) { + char* message = (char*)sqlite3_column_text(stmt, 0); + char* date = (char*)sqlite3_column_text(stmt, 1); + char* from_jid = (char*)sqlite3_column_text(stmt, 2); + char* from_resource = (char*)sqlite3_column_text(stmt, 3); + char* to_jid = (char*)sqlite3_column_text(stmt, 4); + char* to_resource = (char*)sqlite3_column_text(stmt, 5); + char* type = (char*)sqlite3_column_text(stmt, 6); + char* encryption = (char*)sqlite3_column_text(stmt, 7); + char* id = (char*)sqlite3_column_text(stmt, 8); + char* archive_id = (char*)sqlite3_column_text(stmt, 9); + int marked_read_raw = sqlite3_column_type(stmt, 10) == SQLITE_NULL ? -1 : sqlite3_column_int(stmt, 10); + + ProfMessage* msg = message_init(); + msg->id = _db_strdup(id); + msg->stanzaid = _db_strdup(archive_id); + msg->from_jid = jid_create_from_bare_and_resource(from_jid, from_resource); + msg->to_jid = jid_create_from_bare_and_resource(to_jid, to_resource); + msg->plain = _db_strdup(message); + if (!msg->plain) + msg->plain = strdup(""); + msg->timestamp = g_date_time_new_from_iso8601(date, NULL); + msg->type = _get_message_type_type(type); + msg->enc = _get_message_enc_type(encryption); + msg->marked_read = marked_read_raw; + + result = g_slist_prepend(result, msg); + } + sqlite3_finalize(stmt); + + return g_slist_reverse(result); +} + GSList* db_sqlite_list_contacts(void) { diff --git a/src/xmpp/message.c b/src/xmpp/message.c index 9af7b8c4..a8051c61 100644 --- a/src/xmpp/message.c +++ b/src/xmpp/message.c @@ -358,6 +358,7 @@ message_init(void) message->enc = PROF_MSG_ENC_NONE; message->trusted = true; message->type = PROF_MSG_TYPE_UNINITIALIZED; + message->marked_read = -1; return message; } diff --git a/src/xmpp/xmpp.h b/src/xmpp/xmpp.h index ff320e80..0f2e7812 100644 --- a/src/xmpp/xmpp.h +++ b/src/xmpp/xmpp.h @@ -175,6 +175,7 @@ typedef struct prof_message_t gboolean trusted; gboolean is_mam; prof_msg_type_t type; + int marked_read; // -1 = unset, 0 = unread, 1 = read (used by export/import) } ProfMessage; void session_init(void); diff --git a/tests/functionaltests/functionaltests.c b/tests/functionaltests/functionaltests.c index 14ea36ba..b9c35214 100644 --- a/tests/functionaltests/functionaltests.c +++ b/tests/functionaltests/functionaltests.c @@ -16,9 +16,9 @@ * * Tests are organized into groups for better maintainability and parallel execution: * Group 1: Connect, Ping, Rooms, Software, Last Activity, Autoping - * Group 2: Message, Receipts, Roster, DB History (+2 with SQLite) - * Group 3: Chat Session, Presence, Disconnect, Disco - * Group 4: MUC, Carbons + * Group 2: Message, Receipts, Roster, DB History (+export/import with SQLite) + * Group 3: Presence, Disconnect, Disco + * Group 4: Chat Session, MUC, Carbons * * Parallel execution: * ./functionaltests - run all tests sequentially @@ -85,7 +85,6 @@ main(int argc, char* argv[]) /* ============================================================ * GROUP 1: Connect, Ping, Rooms, Software, Last Activity * Basic XMPP session establishment and server queries - * (19 tests) * ============================================================ */ const struct CMUnitTest group1_tests[] = { /* Connection tests - verify login, roster, bookmarks */ @@ -131,13 +130,20 @@ main(int argc, char* argv[]) /* ============================================================ * GROUP 2: Message, Receipts, Roster, DB History * Core messaging and contact management - * (25 tests with SQLite, 23 without) * ============================================================ */ const struct CMUnitTest group2_tests[] = { #ifdef HAVE_SQLITE /* Export/Import — cross-backend migration (SQLite ↔ flat-file) */ PROF_FUNC_TEST(export_sqlite_to_flatfile), PROF_FUNC_TEST(import_flatfile_to_sqlite), + PROF_FUNC_TEST(export_idempotent_no_duplicates), + PROF_FUNC_TEST(export_lmc_correction_survives), + PROF_FUNC_TEST(switch_preserves_old_backend_data), + PROF_FUNC_TEST(export_all_contacts), + PROF_FUNC_TEST(import_double_dedup), + PROF_FUNC_TEST(verify_after_export), + PROF_FUNC_TEST(switch_backends_independent_messages), + PROF_FUNC_TEST(export_empty_contact), #endif /* Basic message send/receive */ @@ -173,19 +179,10 @@ main(int argc, char* argv[]) }; /* ============================================================ - * GROUP 3: Chat Session, Presence, Disconnect - * Session routing, status management, clean teardown - * (22 tests) + * GROUP 3: Presence, Disconnect, Disco + * Status management, clean teardown, service discovery * ============================================================ */ const struct CMUnitTest group3_tests[] = { - /* Chat session management - bare/full JID routing */ - PROF_FUNC_TEST(sends_message_to_barejid_when_contact_offline), - PROF_FUNC_TEST(sends_message_to_barejid_when_contact_online), - PROF_FUNC_TEST(sends_message_to_fulljid_when_received_from_fulljid), - PROF_FUNC_TEST(sends_subsequent_messages_to_fulljid), - PROF_FUNC_TEST(resets_to_barejid_after_presence_received), - PROF_FUNC_TEST(new_session_when_message_received_from_different_fulljid), - /* Presence - online/away/xa/dnd/chat status management */ PROF_FUNC_TEST(presence_online), PROF_FUNC_TEST(presence_online_with_message), @@ -224,11 +221,18 @@ main(int argc, char* argv[]) }; /* ============================================================ - * GROUP 4: MUC, Carbons - * Multi-user chat and message synchronization - * (21 tests) + * GROUP 4: Chat Session, MUC, Carbons + * Session routing, multi-user chat, message synchronization * ============================================================ */ const struct CMUnitTest group4_tests[] = { + /* Chat session management - bare/full JID routing */ + PROF_FUNC_TEST(sends_message_to_barejid_when_contact_offline), + PROF_FUNC_TEST(sends_message_to_barejid_when_contact_online), + PROF_FUNC_TEST(sends_message_to_fulljid_when_received_from_fulljid), + PROF_FUNC_TEST(sends_subsequent_messages_to_fulljid), + PROF_FUNC_TEST(resets_to_barejid_after_presence_received), + PROF_FUNC_TEST(new_session_when_message_received_from_different_fulljid), + /* MUC room join with various options - XEP-0045 */ PROF_FUNC_TEST(sends_room_join), PROF_FUNC_TEST(sends_room_join_with_nick), @@ -271,8 +275,8 @@ main(int argc, char* argv[]) } groups[] = { { "Group 1: Connect/Ping/Rooms/Software/LastActivity/Autoping", group1_tests, ARRAY_SIZE(group1_tests) }, { "Group 2: Message/Receipts/Roster/DBHistory", group2_tests, ARRAY_SIZE(group2_tests) }, - { "Group 3: Session/Presence/Disconnect/Disco", group3_tests, ARRAY_SIZE(group3_tests) }, - { "Group 4: MUC/Carbons", group4_tests, ARRAY_SIZE(group4_tests) }, + { "Group 3: Presence/Disconnect/Disco", group3_tests, ARRAY_SIZE(group3_tests) }, + { "Group 4: Session/MUC/Carbons", group4_tests, ARRAY_SIZE(group4_tests) }, }; const int num_groups = ARRAY_SIZE(groups); diff --git a/tests/functionaltests/test_export_import.c b/tests/functionaltests/test_export_import.c index 9de71788..edb6b3b1 100644 --- a/tests/functionaltests/test_export_import.c +++ b/tests/functionaltests/test_export_import.c @@ -112,3 +112,390 @@ import_flatfile_to_sqlite(void** state) assert_true(prof_output_regex("Flatfile reply at 1445")); assert_true(prof_output_regex("Hello from flatfile import test")); } + +/* + * Test: re-export idempotency — export + import + re-export does not + * duplicate messages. + * + * Flow: + * 1. Connect (SQLite), send 2 messages, export to flatfile + * 2. Switch to flatfile, verify 2 messages + * 3. Switch back to SQLite, export again + * 4. Switch to flatfile, reopen — still exactly 2 messages + * (the dedup logic in export must prevent duplication) + */ +void +export_idempotent_no_duplicates(void** state) +{ + prof_connect(); + + /* Create 2 messages in SQLite */ + prof_input("/msg buddy1@localhost idemp-msg-alpha"); + assert_true(prof_output_regex("me: .+idemp-msg-alpha")); + + stbbr_send( + "" + "idemp-msg-beta" + ""); + assert_true(prof_output_regex("Buddy1/res: .+idemp-msg-beta")); + + prof_input("/close"); + + /* Export #1 */ + prof_input("/history export buddy1@localhost"); + assert_true(prof_output_regex("Export complete: 2 message\\(s\\) exported\\.")); + + /* Verify flatfile has the messages */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("idemp-msg-alpha")); + assert_true(prof_output_exact("idemp-msg-beta")); + prof_input("/close"); + + /* Switch back to SQLite and export again (re-export) */ + prof_input("/history switch sqlite"); + assert_true(prof_output_regex("Database backend switched to 'sqlite'\\.")); + + prof_input("/history export buddy1@localhost"); + /* Dedup: 0 new messages exported (all already present in flatfile) */ + assert_true(prof_output_regex("Export complete: 0 message\\(s\\) exported\\.")); + + /* Verify flatfile still has exactly 2, not 4 */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("idemp-msg-alpha")); + assert_true(prof_output_exact("idemp-msg-beta")); +} + +/* + * Test: LMC (Last Message Correction, XEP-0308) survives export. + * + * A corrected message in SQLite should appear as corrected in the + * flat-file after export — the original body must be absent and the + * corrected body must be present. + */ +void +export_lmc_correction_survives(void** state) +{ + prof_connect(); + + prof_input("/history on"); + assert_true(prof_output_regex("Chat history enabled\\.")); + + /* Original message */ + stbbr_send( + "" + "lmc-export-before-fix" + ""); + assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)")); + + /* Correction replacing the original */ + stbbr_send( + "" + "lmc-export-after-fix" + "" + ""); + + /* Sync barrier */ + stbbr_send( + "" + "lmc-export-sync" + ""); + assert_true(prof_output_exact("<< chat message: Buddy2/phone (win 3)")); + + prof_input("/close all"); + assert_true(prof_output_exact("Closed 2 windows.")); + + /* Export to flatfile */ + prof_input("/history export buddy1@localhost"); + assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\.")); + + /* Switch to flatfile and verify correction survived */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("lmc-export-after-fix")); + + /* Original should be gone (correction was applied in SQLite) */ + prof_timeout(3); + assert_false(prof_output_exact("lmc-export-before-fix")); + prof_timeout_reset(); +} + +/* + * Test: /history switch preserves old backend data. + * + * Switching from SQLite to flatfile should not destroy SQLite data. + * Switching back should recover the original messages. + * + * Uses incoming messages received on the console so the body is never + * rendered to the terminal. The negative check (flatfile is empty) + * is performed BEFORE the positive check (SQLite has the message) + * so that the body text has not yet entered the cumulative buffer. + */ +void +switch_preserves_old_backend_data(void** state) +{ + prof_connect(); + + prof_input("/history on"); + assert_true(prof_output_regex("Chat history enabled\\.")); + + /* Receive incoming on console — body NOT rendered to terminal */ + stbbr_send( + "" + "switch-preserve-sqlite-42" + ""); + assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)")); + + /* Close window (destroy in-memory buffer) */ + prof_input("/close 2"); + assert_true(prof_output_exact("Closed window 2")); + + /* Switch to flatfile BEFORE verifying SQLite — body is still + * absent from the cumulative terminal buffer at this point */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + /* Flatfile is empty — body must NOT appear (and it has never + * been rendered yet, so the cumulative buffer is clean) */ + prof_input("/msg buddy1@localhost"); + prof_timeout(3); + assert_false(prof_output_exact("switch-preserve-sqlite-42")); + prof_timeout_reset(); + prof_input("/close"); + + /* Switch back to SQLite — original data should be intact */ + prof_input("/history switch sqlite"); + assert_true(prof_output_regex("Database backend switched to 'sqlite'\\.")); + + /* Now the body is rendered for the first time from DB history */ + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("switch-preserve-sqlite-42")); +} + +/* + * Test: global export (no JID argument) exports all contacts. + * + * Creates messages for buddy1 and buddy2 in SQLite, then exports all. + * Both contacts' messages must appear in flatfile after switch. + */ +void +export_all_contacts(void** state) +{ + prof_connect(); + + /* Messages to buddy1 */ + prof_input("/msg buddy1@localhost global-export-buddy1-aaa"); + assert_true(prof_output_regex("me: .+global-export-buddy1-aaa")); + prof_input("/close"); + + /* Messages to buddy2 */ + prof_input("/msg buddy2@localhost global-export-buddy2-bbb"); + assert_true(prof_output_regex("me: .+global-export-buddy2-bbb")); + prof_input("/close"); + + /* Export all (no JID) */ + prof_input("/history export"); + assert_true(prof_output_regex("Exporting all SQLite history to flat-file\\.\\.\\.")); + assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\.")); + + /* Switch to flatfile */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + /* Verify buddy1 */ + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("global-export-buddy1-aaa")); + prof_input("/close"); + + /* Verify buddy2 */ + prof_input("/msg buddy2@localhost"); + assert_true(prof_output_exact("global-export-buddy2-bbb")); +} + +/* + * Test: double import deduplication. + * + * Importing the same flatfile twice should not create duplicate messages + * in SQLite. + * + * Flow: + * 1. Create messages in flatfile + * 2. Switch to SQLite, import, verify count + * 3. Import again — count should be 0 (all deduplicated) + * 4. Verify only one copy of each message in history + */ +void +import_double_dedup(void** state) +{ + prof_connect(); + + /* Create messages in flatfile */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + prof_input("/msg buddy1@localhost dedup-import-msg-one"); + assert_true(prof_output_regex("me: .+dedup-import-msg-one")); + + stbbr_send( + "" + "dedup-import-msg-two" + ""); + assert_true(prof_output_regex("Buddy1/res: .+dedup-import-msg-two")); + prof_input("/close"); + + /* Switch to SQLite and import #1 */ + prof_input("/history switch sqlite"); + assert_true(prof_output_regex("Database backend switched to 'sqlite'\\.")); + + prof_input("/history import buddy1@localhost"); + assert_true(prof_output_regex("Import complete: 2 message\\(s\\) imported\\.")); + + /* Import #2 — should be all duplicates */ + prof_input("/history import buddy1@localhost"); + assert_true(prof_output_regex("Import complete: 0 message\\(s\\) imported\\.")); + + /* Verify messages exist and are not doubled */ + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("dedup-import-msg-one")); + assert_true(prof_output_exact("dedup-import-msg-two")); +} + +/* + * Test: /history verify on data that went through export/import. + * + * After migrating SQLite → flatfile via export, /history verify should + * complete without errors on the resulting flat-files. + */ +void +verify_after_export(void** state) +{ + prof_connect(); + + /* Create some messages in SQLite */ + prof_input("/msg buddy1@localhost verify-after-export-msg"); + assert_true(prof_output_regex("me: .+verify-after-export-msg")); + + stbbr_send( + "" + "verify-export-reply" + ""); + assert_true(prof_output_regex("Buddy1/res: .+verify-export-reply")); + prof_input("/close"); + + /* Export to flatfile */ + prof_input("/history export buddy1@localhost"); + assert_true(prof_output_regex("Export complete: [0-9]+ message\\(s\\) exported\\.")); + + /* Switch to flatfile and verify integrity */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + prof_input("/history verify buddy1@localhost"); + /* Accept "no issues found" or "0 error(s)" — minor info notes are OK */ + assert_true(prof_output_regex("Verification complete")); +} + +/* + * Test: each backend maintains its own independent messages. + * + * After switching backends, new messages go to the new backend only. + * Switching back reveals the original backend's messages unchanged. + * + * Uses incoming messages on the console (body never rendered) and two + * different contacts (buddy1 for SQLite, buddy2 for flatfile). + * + * All negative (assert_false) checks are done BEFORE any positive + * check renders a body, because prof_output_exact searches the + * cumulative terminal buffer. + */ +void +switch_backends_independent_messages(void** state) +{ + prof_connect(); + + prof_input("/history on"); + assert_true(prof_output_regex("Chat history enabled\\.")); + + /* Phase 1: receive buddy1 msg while on SQLite — body NOT rendered */ + stbbr_send( + "" + "indep-sqlite-msg-7k" + ""); + assert_true(prof_output_exact("<< chat message: Buddy1/phone (win 2)")); + prof_input("/close 2"); + assert_true(prof_output_exact("Closed window 2")); + + /* Phase 2: switch to flatfile, receive buddy2 msg */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + stbbr_send( + "" + "indep-flatfile-msg-9m" + ""); + assert_true(prof_output_exact("<< chat message: Buddy2/laptop (win 2)")); + prof_input("/close 2"); + assert_true(prof_output_exact("Closed window 2")); + + /* --- Negative checks first (neither body has been rendered yet) --- */ + + /* buddy1 on flatfile — should be empty (received on SQLite) */ + prof_input("/msg buddy1@localhost"); + prof_timeout(3); + assert_false(prof_output_exact("indep-sqlite-msg-7k")); + prof_timeout_reset(); + prof_input("/close"); + + /* Switch to SQLite for buddy2 negative check */ + prof_input("/history switch sqlite"); + assert_true(prof_output_regex("Database backend switched to 'sqlite'\\.")); + + /* buddy2 on SQLite — should be empty (received on flatfile) */ + prof_input("/msg buddy2@localhost"); + prof_timeout(3); + assert_false(prof_output_exact("indep-flatfile-msg-9m")); + prof_timeout_reset(); + prof_input("/close"); + + /* --- Positive checks (bodies rendered for the first time) --- */ + + /* buddy1 on SQLite — should have the sqlite msg */ + prof_input("/msg buddy1@localhost"); + assert_true(prof_output_exact("indep-sqlite-msg-7k")); + prof_input("/close"); + + /* Switch to flatfile for buddy2 positive check */ + prof_input("/history switch flatfile"); + assert_true(prof_output_regex("Database backend switched to 'flatfile'\\.")); + + /* buddy2 on flatfile — should have the flatfile msg */ + prof_input("/msg buddy2@localhost"); + assert_true(prof_output_exact("indep-flatfile-msg-9m")); +} + +/* + * Test: export of a contact with no messages doesn't crash and reports 0. + * + * buddy2 has no message history — export should succeed gracefully + * with 0 messages exported. + */ +void +export_empty_contact(void** state) +{ + prof_connect(); + + /* buddy2 has never exchanged messages */ + prof_input("/history export buddy2@localhost"); + assert_true(prof_output_regex("Export complete: 0 message\\(s\\) exported\\.")); +} diff --git a/tests/functionaltests/test_export_import.h b/tests/functionaltests/test_export_import.h index c2ea2bbf..2bfdd2a7 100644 --- a/tests/functionaltests/test_export_import.h +++ b/tests/functionaltests/test_export_import.h @@ -1,2 +1,10 @@ void export_sqlite_to_flatfile(void** state); void import_flatfile_to_sqlite(void** state); +void export_idempotent_no_duplicates(void** state); +void export_lmc_correction_survives(void** state); +void switch_preserves_old_backend_data(void** state); +void export_all_contacts(void** state); +void import_double_dedup(void** state); +void verify_after_export(void** state); +void switch_backends_independent_messages(void** state); +void export_empty_contact(void** state); diff --git a/tests/unittests/database/stub_database.c b/tests/unittests/database/stub_database.c index 4509433f..48a29dbb 100644 --- a/tests/unittests/database/stub_database.c +++ b/tests/unittests/database/stub_database.c @@ -88,6 +88,23 @@ db_sqlite_list_contacts(void) { return NULL; } +GSList* +db_sqlite_get_all_chat(const gchar* const contact_barejid) +{ + return NULL; +} +void +db_sqlite_begin_transaction(void) +{ +} +void +db_sqlite_end_transaction(void) +{ +} +void +db_sqlite_rollback_transaction(void) +{ +} int log_database_export_to_flatfile(const gchar* const contact_jid) { diff --git a/tests/unittests/test_database_export.c b/tests/unittests/test_database_export.c index 3ca20691..0a820973 100644 --- a/tests/unittests/test_database_export.c +++ b/tests/unittests/test_database_export.c @@ -28,7 +28,9 @@ static ff_parsed_line_t* _roundtrip(const char* timestamp, const char* type, const char* enc, const char* stanza_id, const char* archive_id, const char* replace_id, - const char* from_jid, const char* from_resource, const char* message) + const char* from_jid, const char* from_resource, + const char* to_jid, const char* to_resource, int marked_read, + const char* message) { char tmppath[] = "/tmp/proftest_rt_XXXXXX"; int fd = mkstemp(tmppath); @@ -39,7 +41,9 @@ _roundtrip(const char* timestamp, const char* type, const char* enc, ff_write_line(fp, timestamp, type, enc, stanza_id, archive_id, replace_id, - from_jid, from_resource, message); + from_jid, from_resource, + to_jid, to_resource, marked_read, + message); fclose(fp); /* Read & parse */ @@ -70,7 +74,9 @@ test_ff_roundtrip_simple_chat(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-06-15T12:30:00+00:00", "chat", "none", NULL, NULL, NULL, - "alice@example.com", NULL, "Hello, world!"); + "alice@example.com", NULL, + NULL, NULL, -1, + "Hello, world!"); assert_non_null(pl); assert_string_equal(pl->timestamp_str, "2025-06-15T12:30:00+00:00"); @@ -90,7 +96,9 @@ test_ff_roundtrip_with_all_metadata(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-06-15T12:30:00+00:00", "chat", "omemo", "sid-abc-123", "aid-xyz-789", "corrects-old-id", - "bob@example.com", "phone", "Encrypted message."); + "bob@example.com", "phone", + NULL, NULL, -1, + "Encrypted message."); assert_non_null(pl); assert_string_equal(pl->stanza_id, "sid-abc-123"); @@ -109,7 +117,9 @@ test_ff_roundtrip_with_resource(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-01-01T00:00:00Z", "chat", "none", NULL, NULL, NULL, - "user@jabber.org", "Profanity.abc123", "hi"); + "user@jabber.org", "Profanity.abc123", + NULL, NULL, -1, + "hi"); assert_non_null(pl); assert_string_equal(pl->from_jid, "user@jabber.org"); @@ -124,7 +134,9 @@ test_ff_roundtrip_newline_in_body(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", NULL, NULL, NULL, - "alice@x.com", NULL, "line1\nline2\nline3"); + "alice@x.com", NULL, + NULL, NULL, -1, + "line1\nline2\nline3"); assert_non_null(pl); assert_string_equal(pl->message, "line1\nline2\nline3"); @@ -138,7 +150,9 @@ test_ff_roundtrip_pipe_in_stanza_id(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", "id|with|pipes", "aid|also|has|pipes", NULL, - "a@b.com", NULL, "test"); + "a@b.com", NULL, + NULL, NULL, -1, + "test"); assert_non_null(pl); assert_string_equal(pl->stanza_id, "id|with|pipes"); @@ -152,7 +166,9 @@ test_ff_roundtrip_backslash_in_body(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", NULL, NULL, NULL, - "a@b.com", NULL, "path\\to\\file C:\\Users\\test"); + "a@b.com", NULL, + NULL, NULL, -1, + "path\\to\\file C:\\Users\\test"); assert_non_null(pl); assert_string_equal(pl->message, "path\\to\\file C:\\Users\\test"); @@ -165,7 +181,9 @@ test_ff_roundtrip_unicode_body(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", NULL, NULL, NULL, - "a@b.com", NULL, "Привет мир 🌍 日本語テスト"); + "a@b.com", NULL, + NULL, NULL, -1, + "Привет мир 🌍 日本語テスト"); assert_non_null(pl); assert_string_equal(pl->message, "Привет мир 🌍 日本語テスト"); @@ -178,7 +196,9 @@ test_ff_roundtrip_empty_body(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", NULL, NULL, NULL, - "a@b.com", NULL, ""); + "a@b.com", NULL, + NULL, NULL, -1, + ""); assert_non_null(pl); assert_string_equal(pl->message, ""); @@ -192,7 +212,9 @@ test_ff_roundtrip_colonspace_in_resource(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", NULL, NULL, NULL, - "a@b.com", "res: with: colons", "msg"); + "a@b.com", "res: with: colons", + NULL, NULL, -1, + "msg"); assert_non_null(pl); assert_string_equal(pl->from_jid, "a@b.com"); @@ -207,7 +229,9 @@ test_ff_roundtrip_muc_type(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "muc", "none", NULL, NULL, NULL, - "room@conference.x.com", "nick", "hello room"); + "room@conference.x.com", "nick", + NULL, NULL, -1, + "hello room"); assert_non_null(pl); assert_string_equal(pl->type, "muc"); @@ -222,7 +246,9 @@ test_ff_roundtrip_omemo_enc(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "omemo", "sid-1", NULL, NULL, - "a@b.com", NULL, "secret"); + "a@b.com", NULL, + NULL, NULL, -1, + "secret"); assert_non_null(pl); assert_string_equal(pl->enc, "omemo"); @@ -236,7 +262,9 @@ test_ff_roundtrip_replace_id(void** state) ff_parsed_line_t* pl = _roundtrip( "2025-03-01T10:00:00Z", "chat", "none", "new-id", NULL, "old-id-to-correct", - "a@b.com", NULL, "corrected text"); + "a@b.com", NULL, + NULL, NULL, -1, + "corrected text"); assert_non_null(pl); assert_string_equal(pl->stanza_id, "new-id"); @@ -245,6 +273,55 @@ test_ff_roundtrip_replace_id(void** state) ff_parsed_line_free(pl); } +void +test_ff_roundtrip_to_jid_and_marked_read(void** state) +{ + /* to_jid, to_resource, and marked_read round-trip */ + ff_parsed_line_t* pl = _roundtrip( + "2025-06-15T12:30:00+00:00", "chat", "none", + "sid-1", NULL, NULL, + "alice@example.com", "phone", + "bob@example.com", "laptop", 1, + "Hello Bob!"); + + assert_non_null(pl); + assert_string_equal(pl->from_jid, "alice@example.com"); + assert_string_equal(pl->from_resource, "phone"); + assert_string_equal(pl->to_jid, "bob@example.com"); + assert_string_equal(pl->to_resource, "laptop"); + assert_int_equal(pl->marked_read, 1); + assert_string_equal(pl->message, "Hello Bob!"); + ff_parsed_line_free(pl); + + /* marked_read = 0 (unread) */ + pl = _roundtrip( + "2025-06-15T12:31:00+00:00", "chat", "none", + NULL, NULL, NULL, + "bob@example.com", NULL, + "alice@example.com", NULL, 0, + "Reply"); + + assert_non_null(pl); + assert_string_equal(pl->to_jid, "alice@example.com"); + assert_null(pl->to_resource); + assert_int_equal(pl->marked_read, 0); + ff_parsed_line_free(pl); + + /* marked_read = -1 (unset) — should NOT appear in output */ + pl = _roundtrip( + "2025-06-15T12:32:00+00:00", "chat", "none", + NULL, NULL, NULL, + "a@b.com", NULL, + NULL, NULL, -1, + "no read flag"); + + assert_non_null(pl); + assert_null(pl->to_jid); + assert_null(pl->to_resource); + assert_int_equal(pl->marked_read, -1); + ff_parsed_line_free(pl); +} + /* ================================================================ * Escape / unescape symmetry tests * ================================================================ */ @@ -442,3 +519,203 @@ test_ff_enc_str_roundtrip(void** state) assert_int_equal(PROF_MSG_ENC_OX, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_OX))); assert_int_equal(PROF_MSG_ENC_OMEMO, ff_get_message_enc_type(ff_get_message_enc_str(PROF_MSG_ENC_OMEMO))); } + +/* ================================================================ + * Additional round-trip tests + * ================================================================ */ + +void +test_ff_roundtrip_bracket_in_stanza_id(void** state) +{ + /* ']' in stanza_id must be escaped as \] to avoid breaking metadata parser */ + ff_parsed_line_t* pl = _roundtrip( + "2025-03-01T10:00:00Z", "chat", "none", + "id]with]brackets", "aid]also]has]brackets", NULL, + "a@b.com", NULL, + NULL, NULL, -1, + "test brackets"); + + assert_non_null(pl); + assert_string_equal(pl->stanza_id, "id]with]brackets"); + assert_string_equal(pl->archive_id, "aid]also]has]brackets"); + assert_string_equal(pl->message, "test brackets"); + ff_parsed_line_free(pl); +} + +void +test_ff_roundtrip_backslash_in_resource(void** state) +{ + /* backslash in from_resource must survive round-trip */ + ff_parsed_line_t* pl = _roundtrip( + "2025-03-01T10:00:00Z", "chat", "none", + NULL, NULL, NULL, + "a@b.com", "res\\with\\backslash", + NULL, NULL, -1, + "msg"); + + assert_non_null(pl); + assert_string_equal(pl->from_jid, "a@b.com"); + assert_string_equal(pl->from_resource, "res\\with\\backslash"); + assert_string_equal(pl->message, "msg"); + ff_parsed_line_free(pl); +} + +void +test_ff_roundtrip_mucpm_type(void** state) +{ + ff_parsed_line_t* pl = _roundtrip( + "2025-03-01T10:00:00Z", "mucpm", "none", + NULL, NULL, NULL, + "room@conference.x.com", "nick", + NULL, NULL, -1, + "private message"); + + assert_non_null(pl); + assert_string_equal(pl->type, "mucpm"); + assert_string_equal(pl->from_resource, "nick"); + ff_parsed_line_free(pl); +} + +void +test_ff_roundtrip_all_enc_types(void** state) +{ + const char* enc_types[] = { "none", "otr", "pgp", "ox", "omemo" }; + for (size_t i = 0; i < sizeof(enc_types) / sizeof(enc_types[0]); i++) { + ff_parsed_line_t* pl = _roundtrip( + "2025-03-01T10:00:00Z", "chat", enc_types[i], + NULL, NULL, NULL, + "a@b.com", NULL, + NULL, NULL, -1, + "msg"); + + assert_non_null(pl); + assert_string_equal(pl->enc, enc_types[i]); + ff_parsed_line_free(pl); + } +} + +void +test_ff_roundtrip_crlf_handling(void** state) +{ + /* Write a line, then read it with \r\n ending — parser should strip \r */ + char tmppath[] = "/tmp/proftest_crlf_XXXXXX"; + int fd = mkstemp(tmppath); + assert_true(fd >= 0); + + FILE* fp = fdopen(fd, "w"); + assert_non_null(fp); + + /* Write a raw line with \r\n ending */ + fprintf(fp, "2025-03-01T10:00:00Z [chat|none] a@b.com: hello\r\n"); + fclose(fp); + + fp = fopen(tmppath, "r"); + assert_non_null(fp); + + gboolean truncated = FALSE; + char* buf = ff_readline(fp, &truncated); + fclose(fp); + unlink(tmppath); + + assert_non_null(buf); + ff_parsed_line_t* pl = ff_parse_line(buf); + free(buf); + + assert_non_null(pl); + assert_string_equal(pl->from_jid, "a@b.com"); + assert_string_equal(pl->message, "hello"); + ff_parsed_line_free(pl); +} + +void +test_ff_roundtrip_to_jid_special_chars(void** state) +{ + /* to: and to_res: with pipe and bracket chars that need escaping */ + ff_parsed_line_t* pl = _roundtrip( + "2025-03-01T10:00:00Z", "chat", "none", + NULL, NULL, NULL, + "a@b.com", NULL, + "to|user@c.com", "res]with|special", -1, + "msg with special to"); + + assert_non_null(pl); + assert_string_equal(pl->to_jid, "to|user@c.com"); + assert_string_equal(pl->to_resource, "res]with|special"); + assert_string_equal(pl->message, "msg with special to"); + ff_parsed_line_free(pl); +} + +void +test_ff_roundtrip_multiple_lines(void** state) +{ + /* Write several lines, read and parse them all sequentially */ + char tmppath[] = "/tmp/proftest_multi_XXXXXX"; + int fd = mkstemp(tmppath); + assert_true(fd >= 0); + + FILE* fp = fdopen(fd, "w"); + assert_non_null(fp); + + ff_write_line(fp, "2025-01-01T00:00:01Z", "chat", "none", + NULL, NULL, NULL, "a@b.com", NULL, + NULL, NULL, -1, "first"); + ff_write_line(fp, "2025-01-01T00:00:02Z", "chat", "none", + NULL, NULL, NULL, "c@d.com", NULL, + NULL, NULL, -1, "second"); + ff_write_line(fp, "2025-01-01T00:00:03Z", "muc", "omemo", + "sid-3", NULL, NULL, "room@conf.com", "nick", + NULL, NULL, -1, "third"); + fclose(fp); + + fp = fopen(tmppath, "r"); + assert_non_null(fp); + + int count = 0; + const char* expected_msgs[] = { "first", "second", "third" }; + const char* expected_from[] = { "a@b.com", "c@d.com", "room@conf.com" }; + + while (1) { + gboolean truncated = FALSE; + char* buf = ff_readline(fp, &truncated); + if (!buf) + break; + ff_parsed_line_t* pl = ff_parse_line(buf); + free(buf); + if (pl) { + assert_true(count < 3); + assert_string_equal(pl->message, expected_msgs[count]); + assert_string_equal(pl->from_jid, expected_from[count]); + ff_parsed_line_free(pl); + count++; + } + } + fclose(fp); + unlink(tmppath); + + assert_int_equal(count, 3); +} + +/* ================================================================ + * Additional parser edge-case tests + * ================================================================ */ + +void +test_ff_parsed_line_free_null_safe(void** state) +{ + /* ff_parsed_line_free(NULL) must not crash */ + ff_parsed_line_free(NULL); +} + +void +test_ff_parse_line_no_space_rejected(void** state) +{ + /* A line with no spaces at all cannot be parsed */ + assert_null(ff_parse_line("noseparatoratall")); +} + +void +test_ff_parse_line_unclosed_bracket(void** state) +{ + /* Unclosed metadata bracket should return NULL */ + assert_null(ff_parse_line("2025-03-01T10:00:00Z [chat|none a@b.com: msg")); +} diff --git a/tests/unittests/test_database_export.h b/tests/unittests/test_database_export.h index 88f07bc9..f1b91e60 100644 --- a/tests/unittests/test_database_export.h +++ b/tests/unittests/test_database_export.h @@ -14,6 +14,7 @@ void test_ff_roundtrip_colonspace_in_resource(void** state); void test_ff_roundtrip_muc_type(void** state); void test_ff_roundtrip_omemo_enc(void** state); void test_ff_roundtrip_replace_id(void** state); +void test_ff_roundtrip_to_jid_and_marked_read(void** state); /* escape / unescape symmetry */ void test_ff_escape_unescape_message_identity(void** state); @@ -37,3 +38,17 @@ void test_ff_parse_line_invalid_timestamp(void** state); /* type/enc conversion round-trip */ void test_ff_type_str_roundtrip(void** state); void test_ff_enc_str_roundtrip(void** state); + +/* additional round-trip */ +void test_ff_roundtrip_bracket_in_stanza_id(void** state); +void test_ff_roundtrip_backslash_in_resource(void** state); +void test_ff_roundtrip_mucpm_type(void** state); +void test_ff_roundtrip_all_enc_types(void** state); +void test_ff_roundtrip_crlf_handling(void** state); +void test_ff_roundtrip_to_jid_special_chars(void** state); +void test_ff_roundtrip_multiple_lines(void** state); + +/* additional parser edge cases */ +void test_ff_parsed_line_free_null_safe(void** state); +void test_ff_parse_line_no_space_rejected(void** state); +void test_ff_parse_line_unclosed_bracket(void** state); diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index 26cf6bde..7048fd09 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -671,6 +671,7 @@ main(int argc, char* argv[]) cmocka_unit_test(test_ff_roundtrip_muc_type), cmocka_unit_test(test_ff_roundtrip_omemo_enc), cmocka_unit_test(test_ff_roundtrip_replace_id), + cmocka_unit_test(test_ff_roundtrip_to_jid_and_marked_read), // Escape / unescape cmocka_unit_test(test_ff_escape_unescape_message_identity), @@ -694,6 +695,20 @@ main(int argc, char* argv[]) // Type/enc round-trip cmocka_unit_test(test_ff_type_str_roundtrip), cmocka_unit_test(test_ff_enc_str_roundtrip), + + // Additional round-trip + cmocka_unit_test(test_ff_roundtrip_bracket_in_stanza_id), + cmocka_unit_test(test_ff_roundtrip_backslash_in_resource), + cmocka_unit_test(test_ff_roundtrip_mucpm_type), + cmocka_unit_test(test_ff_roundtrip_all_enc_types), + cmocka_unit_test(test_ff_roundtrip_crlf_handling), + cmocka_unit_test(test_ff_roundtrip_to_jid_special_chars), + cmocka_unit_test(test_ff_roundtrip_multiple_lines), + + // Additional parser edge cases + cmocka_unit_test(test_ff_parsed_line_free_null_safe), + cmocka_unit_test(test_ff_parse_line_no_space_rejected), + cmocka_unit_test(test_ff_parse_line_unclosed_bracket), }; return cmocka_run_group_tests(all_tests, NULL, NULL); }