diff --git a/tests/test_web_sessions_browser.py b/tests/test_web_sessions_browser.py new file mode 100644 index 0000000..5acafab --- /dev/null +++ b/tests/test_web_sessions_browser.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Exercise Web Sessions through embedded production assets in real Firefox.""" + +import os +import signal +import socket +import sqlite3 +import subprocess +import tempfile +import time +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui" +GECKO = Path( + os.environ.get( + "TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver" + ) +) +EVIDENCE = ROOT / "docs" / "reviews" / "evidence" + + +class BrowserSessionsTest(unittest.TestCase): + def test_create_reload_detail_and_mobile_editor(self): + if not GECKO.is_file(): + self.skipTest("pinned geckodriver unavailable") + + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.firefox.service import Service + from selenium.webdriver.support.ui import WebDriverWait + + with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory: + root = Path(directory) + xdg = root / "data" + xdg.mkdir() + with socket.socket() as reserved: + reserved.bind(("127.0.0.1", 0)) + port = reserved.getsockname()[1] + environment = os.environ.copy() + environment.update( + { + "XDG_DATA_HOME": str(xdg), + "XDG_CONFIG_HOME": str(root / "config"), + "XDG_CACHE_HOME": str(root / "cache"), + } + ) + server = subprocess.Popen( + [str(BUILD / "trainlog"), "--web", "--port", str(port)], + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + driver = None + try: + for _ in range(200): + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", port)) == 0: + break + time.sleep(0.025) + + options = webdriver.FirefoxOptions() + options.add_argument("-headless") + driver = webdriver.Firefox(options=options, service=Service(str(GECKO))) + wait = WebDriverWait(driver, 15) + driver.set_window_size(1440, 1000) + driver.get(f"http://127.0.0.1:{port}/seances") + wait.until(lambda current: "Aucun résultat" in current.page_source) + EVIDENCE.mkdir(parents=True, exist_ok=True) + driver.save_screenshot(str(EVIDENCE / "web-sessions-list-desktop.png")) + + driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click() + wait.until(lambda current: "Nouvelle séance" in current.page_source) + title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input") + title.send_keys("Séance navigateur éphémère") + driver.set_window_size(390, 844) + driver.save_screenshot(str(EVIDENCE / "web-sessions-editor-mobile.png")) + save = driver.find_element(By.XPATH, "//button[normalize-space()='Enregistrer']") + driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save) + save.click() + wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source) + driver.refresh() + wait.until(lambda current: "Séance navigateur éphémère" in current.page_source) + driver.set_window_size(1440, 1000) + driver.save_screenshot(str(EVIDENCE / "web-sessions-detail-desktop.png")) + + database_path = xdg / "trainlog" / "trainlog.db" + with sqlite3.connect(database_path) as database: + row = database.execute( + "SELECT r.title, p.editing_state " + "FROM session_preparations p " + "JOIN session_preparation_revisions r " + "ON r.revision_id = p.current_revision_id" + ).fetchone() + entry_count = database.execute( + "SELECT COUNT(*) FROM session_preparation_entries" + ).fetchone()[0] + self.assertEqual(row, ("Séance navigateur éphémère", "draft")) + self.assertEqual(entry_count, 0) + finally: + if driver is not None: + driver.quit() + os.killpg(server.pid, signal.SIGTERM) + server.wait(timeout=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h index 1ad9dd9..9f79ff8 100644 --- a/tui/include/trainlog/id.h +++ b/tui/include/trainlog/id.h @@ -1,7 +1,8 @@ /* * Trainlog id interface. * - * Declares the module boundary and ownership contract; implementation and persistence remain in their owning modules. + * Declares the module boundary and ownership contract; implementation and persistence remain in + * their owning modules. */ #ifndef TRAINLOG_ID_H #define TRAINLOG_ID_H @@ -25,8 +26,7 @@ * CONTRACT: occurrence identities use the longer `sxe` prefix while the * frozen v1 object identities retain their existing two-character prefixes. */ -#define TRAINLOG_GENERATED_ID_CAPACITY \ - (3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U) +#define TRAINLOG_GENERATED_ID_CAPACITY (3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U) /** * @brief Generate an official Trainlog identifier using UUID version 4. @@ -56,10 +56,6 @@ * Existing two-character prefixes still require 40 bytes; `sxe` * requires TRAINLOG_GENERATED_ID_CAPACITY bytes. */ -TrainlogStatus trainlog_id_generate( - const char *prefix, - char *output, - size_t output_size -); +TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size); #endif diff --git a/tui/src/id.c b/tui/src/id.c index 230a097..6ad702f 100644 --- a/tui/src/id.c +++ b/tui/src/id.c @@ -11,30 +11,18 @@ #include -static bool prefix_is_supported(const char *prefix) -{ +static bool prefix_is_supported(const char *prefix) { /* * Keep official creator prefixes deliberately small and explicit. * The exchange parser remains more permissive because imported v1 IDs are * opaque values governed by the frozen wire-schema syntax. */ - return strcmp(prefix, "ex") == 0 || - strcmp(prefix, "se") == 0 || - strcmp(prefix, "bo") == 0 || - strcmp(prefix, "sy") == 0 || - strcmp(prefix, "sxe") == 0 || - strcmp(prefix, "sp") == 0 || - strcmp(prefix, "spr") == 0 || - strcmp(prefix, "spe") == 0 || - strcmp(prefix, "spd") == 0; + return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 || + strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 || + strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0; } -TrainlogStatus trainlog_id_generate( - const char *prefix, - char *output, - size_t output_size -) -{ +TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) { uuid_t value; char uuid_text[TRAINLOG_UUID_TEXT_LENGTH + 1U]; int written; @@ -55,16 +43,9 @@ TrainlogStatus trainlog_id_generate( uuid_generate_random(value); uuid_unparse_lower(value, uuid_text); - written = snprintf( - output, - output_size, - "%s_%s", - prefix, - uuid_text - ); + written = snprintf(output, output_size, "%s_%s", prefix, uuid_text); - if (written < 0 || - (size_t)written >= output_size) { + if (written < 0 || (size_t)written >= output_size) { return TRAINLOG_STATUS_SYSTEM_ERROR; } diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 274270c..a16829e 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -429,6 +429,12 @@ static bool is_ui_route(const char *url) { return true; } } + /* CONTRACT: Sessions detail identities are client-side path segments. A + * direct load must receive the SPA, while the /api guard below remains + * authoritative for every adapter route. */ + if (strncmp(url, "/seances/", strlen("/seances/")) == 0) { + return true; + } return false; } @@ -515,7 +521,8 @@ static bool parse_if_match(const char *value, uint64_t *revision) { return true; } -static bool parse_page_number(const char *value, size_t default_value, size_t maximum, size_t *out) { +static bool +parse_page_number(const char *value, size_t default_value, size_t maximum, size_t *out) { char *end = NULL; unsigned long long parsed; @@ -792,21 +799,19 @@ static enum MHD_Result handle_request(void *closure, size_t limit; size_t index; - if (!is_get && - (strcmp(url, "/api/v1/sessions/preparations") == 0 || - strncmp(url, - "/api/v1/sessions/preparation/", - strlen("/api/v1/sessions/preparation/")) == 0)) { - const char *origin = - MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin"); - const char *csrf = MHD_lookup_connection_value( - connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token"); + if (!is_get && (strcmp(url, "/api/v1/sessions/preparations") == 0 || + strncmp(url, + "/api/v1/sessions/preparation/", + strlen("/api/v1/sessions/preparation/")) == 0)) { + const char *origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin"); + const char *csrf = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token"); const char *content_type = MHD_lookup_connection_value( connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE); - const char *request_id = MHD_lookup_connection_value( - connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID"); - const char *if_match = MHD_lookup_connection_value( - connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH); + const char *request_id = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID"); + const char *if_match = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH); const char *identity = NULL; const char *expected = ""; char expected_buffer[TRAINLOG_ID_MAX + 1U]; @@ -825,10 +830,8 @@ static enum MHD_Result handle_request(void *closure, "POST, PUT"); } if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) { - return queue_json(connection, - MHD_HTTP_FORBIDDEN, - "{\"error\":\"mutation_forbidden\"}\n", - NULL); + return queue_json( + connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); } if (content_type == NULL || strcmp(content_type, "application/json") != 0 || request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) { @@ -869,8 +872,7 @@ static enum MHD_Result handle_request(void *closure, NULL); } length = strlen(if_match); - if (length < 3U || length - 2U > TRAINLOG_ID_MAX || - if_match[length - 1U] != '"') { + if (length < 3U || length - 2U > TRAINLOG_ID_MAX || if_match[length - 1U] != '"') { return queue_json(connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_revision\"}\n", @@ -881,12 +883,8 @@ static enum MHD_Result handle_request(void *closure, expected = expected_buffer; } if (delivery) { - status = trainlog_web_sessions_deliver_json(context->database, - identity, - expected, - request_id, - &json, - &json_size); + status = trainlog_web_sessions_deliver_json( + context->database, identity, expected, request_id, &json, &json_size); } else { status = trainlog_web_sessions_save_json(context->database, identity, @@ -926,8 +924,7 @@ static enum MHD_Result handle_request(void *closure, limit_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"); search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search"); if (!parse_page_number(offset_value, 0U, 1000000U, &offset) || - !parse_page_number( - limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) || + !parse_page_number(limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) || limit == 0U) { return queue_json( connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL); @@ -941,8 +938,8 @@ static enum MHD_Result handle_request(void *closure, if (strcmp(url, collections[index].path) == 0) { TrainlogWebSessionsPageQuery query = { collections[index].collection, offset, limit, search}; - TrainlogStatus status = trainlog_web_sessions_list_json( - context->database, &query, &json, &json_size); + TrainlogStatus status = + trainlog_web_sessions_list_json(context->database, &query, &json, &json_size); return queue_owned_sessions_json(connection, status, json, json_size); } } @@ -958,11 +955,8 @@ static enum MHD_Result handle_request(void *closure, (void)memcpy(kind_buffer, kind, (size_t)(separator - kind)); kind_buffer[separator - kind] = '\0'; - status = trainlog_web_sessions_detail_json(context->database, - kind_buffer, - separator + 1, - &json, - &json_size); + status = trainlog_web_sessions_detail_json( + context->database, kind_buffer, separator + 1, &json, &json_size); return queue_owned_sessions_json(connection, status, json, json_size); } } diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c index a7e0f8f..1896363 100644 --- a/tui/src/web_sessions.c +++ b/tui/src/web_sessions.c @@ -20,24 +20,41 @@ typedef struct ListDefinition { static const ListDefinition LISTS[] = { {"preparation", "SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at " - "FROM session_preparations p JOIN session_preparation_revisions r ON r.revision_id=p.current_revision_id " + "FROM session_preparations p JOIN session_preparation_revisions r ON " + "r.revision_id=p.current_revision_id " "LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id " - "WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " - "GROUP BY p.preparation_id ORDER BY COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"}, + "WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM " + "session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE " + "pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " + "GROUP BY p.preparation_id ORDER BY " + "COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"}, {"proposal", - "SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN 'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at " + "SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN " + "'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at " "FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id " - "WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM ai_session_draft_entries ae JOIN exercises x ON x.id=ae.exercise_row_id WHERE ae.draft_row_id=d.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " - "GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 OFFSET ?3"}, + "WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM " + "ai_session_draft_entries ae JOIN exercises x ON x.id=ae.exercise_row_id WHERE " + "ae.draft_row_id=d.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " + "GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 " + "OFFSET ?3"}, {"draft", - "SELECT d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') " - "FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE f.session_id=d.session_id) " - "AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) " - "AND (?1='' OR d.session_type LIKE '%'||?1||'%' COLLATE NOCASE) ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,COALESCE(d.started_at,''),d.session_id LIMIT ?2 OFFSET ?3"}, + "SELECT " + "d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d." + "payload_json,'$.exercises'),0),COALESCE(d.started_at,'') " + "FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " + "f.session_id=d.session_id) " + "AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND " + "c.target_id=d.session_id AND c.deleted=1) " + "AND (?1='' OR d.session_type LIKE '%'||?1||'%' COLLATE NOCASE) ORDER BY CASE d.state WHEN " + "'active' THEN 0 ELSE 1 END,COALESCE(d.started_at,''),d.session_id LIMIT ?2 OFFSET ?3"}, {"history", - "SELECT s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s.started_at " + "SELECT " + "s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s." + "started_at " "FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id " - "WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE he.session_row_id=s.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " + "WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM " + "session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE " + "he.session_row_id=s.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " "GROUP BY s.id ORDER BY s.started_at DESC,s.session_id LIMIT ?2 OFFSET ?3"}, }; @@ -56,9 +73,8 @@ static bool add_nullable_text(yyjson_mut_doc *document, document, object, key, (const char *)sqlite3_column_text(statement, column)); } -static TrainlogStatus write_document(yyjson_mut_doc *document, - char **output_json, - size_t *output_size) { +static TrainlogStatus +write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size) { yyjson_write_err error; char *json; @@ -100,8 +116,9 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database, } if (sqlite3_prepare_v2(database->connection, definition->sql, -1, &statement, NULL) != SQLITE_OK || - sqlite3_bind_text(statement, 1, query->search == NULL ? "" : query->search, -1, - SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text( + statement, 1, query->search == NULL ? "" : query->search, -1, SQLITE_TRANSIENT) != + SQLITE_OK || sqlite3_bind_int64(statement, 2, (sqlite3_int64)(query->limit + 1U)) != SQLITE_OK || sqlite3_bind_int64(statement, 3, (sqlite3_int64)query->offset) != SQLITE_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; @@ -178,11 +195,31 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) { const char *sql = proposal - ? "SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN 'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1" - : "SELECT p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_state FROM session_preparations p JOIN session_preparation_revisions r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1"; - const char *entry_sql = proposal - ? "SELECT e.entry_id,e.position,x.exercise_id,x.name,e.equipment_id,e.recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e.target_weight_kg,e.rest_seconds,NULL FROM ai_session_draft_entries e JOIN exercises x ON x.id=e.exercise_row_id JOIN ai_session_drafts d ON d.id=e.draft_row_id WHERE d.draft_id=?1 ORDER BY e.position,e.entry_id" - : "SELECT e.entry_id,e.position,e.exercise_id,COALESCE(x.name,e.exercise_id),e.equipment_id,e.recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e.target_weight_kg,e.rest_seconds,e.notes FROM session_preparation_entries e LEFT JOIN exercises x ON x.exercise_id=e.exercise_id JOIN session_preparations p ON p.current_revision_id=e.revision_id WHERE p.preparation_id=?1 ORDER BY e.position,e.entry_id"; + ? "SELECT " + "d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes," + "i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN " + "'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN " + "ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1" + : "SELECT " + "p.preparation_id,r.title,r.planned_for,r.session_type,r.notes," + "COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_" + "state FROM session_preparations p JOIN session_preparation_revisions " + "r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1"; + const char *entry_sql = + proposal + ? "SELECT " + "e.entry_id,e.position,x.exercise_id,x.name,e.equipment_id,e.recording_mode,e." + "tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e." + "target_weight_kg,e.rest_seconds,NULL FROM ai_session_draft_entries e JOIN exercises " + "x ON x.id=e.exercise_row_id JOIN ai_session_drafts d ON d.id=e.draft_row_id WHERE " + "d.draft_id=?1 ORDER BY e.position,e.entry_id" + : "SELECT " + "e.entry_id,e.position,e.exercise_id,COALESCE(x.name,e.exercise_id),e.equipment_id,e." + "recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_" + "duration_seconds,e.target_weight_kg,e.rest_seconds,e.notes FROM " + "session_preparation_entries e LEFT JOIN exercises x ON x.exercise_id=e.exercise_id " + "JOIN session_preparations p ON p.current_revision_id=e.revision_id WHERE " + "p.preparation_id=?1 ORDER BY e.position,e.entry_id"; sqlite3_stmt *statement = NULL; yyjson_mut_val *entries; int step; @@ -197,8 +234,14 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, return TRAINLOG_STATUS_NOT_FOUND; } for (column = 0; column < 8; ++column) { - static const char *const keys[] = {"identity", "title", "planned_for", "session_type", - "notes", "source_fingerprint", "revision_id", "state"}; + static const char *const keys[] = {"identity", + "title", + "planned_for", + "session_type", + "notes", + "source_fingerprint", + "revision_id", + "state"}; if (!add_nullable_text(document, root, keys[column], statement, column)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -218,8 +261,13 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, return TRAINLOG_STATUS_DATABASE_ERROR; } while ((step = sqlite3_step(statement)) == SQLITE_ROW) { - static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name", - "equipment_id", "recording_mode", "tracking_mode", + static const char *const text_keys[] = {"entry_id", + NULL, + "exercise_id", + "exercise_name", + "equipment_id", + "recording_mode", + "tracking_mode", "load_mode"}; yyjson_mut_val *entry = yyjson_mut_obj(document); int index; @@ -236,17 +284,23 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database, } } for (index = 8; index <= 12; ++index) { - static const char *const number_keys[] = {"target_sets", "target_reps", - "target_duration_seconds", "target_weight_kg", + static const char *const number_keys[] = {"target_sets", + "target_reps", + "target_duration_seconds", + "target_weight_kg", "rest_seconds"}; if (sqlite3_column_type(statement, index) == SQLITE_NULL) { (void)yyjson_mut_obj_add_null(document, entry, number_keys[index - 8]); } else if (index == 11) { - (void)yyjson_mut_obj_add_real(document, entry, number_keys[index - 8], - sqlite3_column_double(statement, index)); + (void)yyjson_mut_obj_add_real(document, + entry, + number_keys[index - 8], + sqlite3_column_double(statement, index)); } else { - (void)yyjson_mut_obj_add_sint(document, entry, number_keys[index - 8], - sqlite3_column_int64(statement, index)); + (void)yyjson_mut_obj_add_sint(document, + entry, + number_keys[index - 8], + sqlite3_column_int64(statement, index)); } } if (!add_nullable_text(document, entry, "notes", statement, 13) || @@ -265,12 +319,19 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database, const char *identity, yyjson_mut_doc *document, yyjson_mut_val *root) { - static const char SESSION_SQL[] = - "SELECT session_id,session_type,started_at,ended_at,notes FROM sessions WHERE session_id=?1"; + static const char SESSION_SQL[] = "SELECT session_id,session_type,started_at,ended_at,notes " + "FROM sessions WHERE session_id=?1"; static const char ENTRY_SQL[] = - "SELECT se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se.tracking_mode,se.load_mode,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.rest_seconds,se.notes,se.max_weight_kg FROM session_exercises se JOIN exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?1 ORDER BY se.position,se.entry_id"; + "SELECT " + "se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se." + "tracking_mode,se.load_mode,se.target_sets,se.target_reps,se.target_duration_seconds,se." + "target_weight_kg,se.rest_seconds,se.notes,se.max_weight_kg FROM session_exercises se JOIN " + "exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE " + "s.session_id=?1 ORDER BY se.position,se.entry_id"; static const char SET_SQL[] = - "SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 ORDER BY ps.position"; + "SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps JOIN " + "session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 ORDER BY " + "ps.position"; sqlite3_stmt *statement = NULL; sqlite3_stmt *sets_statement = NULL; yyjson_mut_val *entries; @@ -311,15 +372,25 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database, const char *entry_id = (const char *)sqlite3_column_text(statement, 0); int set_step; int column; - static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name", - "equipment_id", "recording_mode", "tracking_mode", + static const char *const text_keys[] = {"entry_id", + NULL, + "exercise_id", + "exercise_name", + "equipment_id", + "recording_mode", + "tracking_mode", "load_mode"}; - static const char *const numeric_keys[] = {"target_sets", "target_reps", - "target_duration_seconds", "target_weight_kg", - "rest_seconds", "notes", "max_weight_kg"}; + static const char *const numeric_keys[] = {"target_sets", + "target_reps", + "target_duration_seconds", + "target_weight_kg", + "rest_seconds", + "notes", + "max_weight_kg"}; if (entry == NULL || sets == NULL || - !yyjson_mut_obj_add_sint(document, entry, "position", sqlite3_column_int64(statement, 1))) { + !yyjson_mut_obj_add_sint( + document, entry, "position", sqlite3_column_int64(statement, 1))) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_SYSTEM_ERROR; } @@ -332,23 +403,25 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database, } for (column = 8; column <= 14; ++column) { if (column == 13) { - (void)add_nullable_text(document, entry, numeric_keys[column - 8], statement, column); + (void)add_nullable_text( + document, entry, numeric_keys[column - 8], statement, column); } else if (sqlite3_column_type(statement, column) == SQLITE_NULL) { (void)yyjson_mut_obj_add_null(document, entry, numeric_keys[column - 8]); } else if (column == 11 || column == 14) { (void)yyjson_mut_obj_add_real(document, - entry, - numeric_keys[column - 8], - sqlite3_column_double(statement, column)); + entry, + numeric_keys[column - 8], + sqlite3_column_double(statement, column)); } else { (void)yyjson_mut_obj_add_sint(document, - entry, - numeric_keys[column - 8], - sqlite3_column_int64(statement, column)); + entry, + numeric_keys[column - 8], + sqlite3_column_int64(statement, column)); } } if (!yyjson_mut_obj_add_val(document, entry, "performed_sets", sets) || - sqlite3_prepare_v2(database->connection, SET_SQL, -1, &sets_statement, NULL) != SQLITE_OK || + sqlite3_prepare_v2(database->connection, SET_SQL, -1, &sets_statement, NULL) != + SQLITE_OK || sqlite3_bind_text(sets_statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { (void)sqlite3_finalize(statement); if (sets_statement != NULL) { @@ -359,7 +432,8 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database, while ((set_step = sqlite3_step(sets_statement)) == SQLITE_ROW) { yyjson_mut_val *set = yyjson_mut_obj(document); if (set == NULL || - !yyjson_mut_obj_add_sint(document, set, "position", sqlite3_column_int64(sets_statement, 0))) { + !yyjson_mut_obj_add_sint( + document, set, "position", sqlite3_column_int64(sets_statement, 0))) { (void)sqlite3_finalize(sets_statement); (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_SYSTEM_ERROR; @@ -370,14 +444,14 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database, (void)yyjson_mut_obj_add_null(document, set, keys[column - 1]); } else if (column == 3) { (void)yyjson_mut_obj_add_real(document, - set, - keys[column - 1], - sqlite3_column_double(sets_statement, column)); + set, + keys[column - 1], + sqlite3_column_double(sets_statement, column)); } else { (void)yyjson_mut_obj_add_sint(document, - set, - keys[column - 1], - sqlite3_column_int64(sets_statement, column)); + set, + keys[column - 1], + sqlite3_column_int64(sets_statement, column)); } } (void)yyjson_mut_arr_add_val(sets, set); @@ -404,9 +478,13 @@ static TrainlogStatus detail_for_draft(TrainlogDatabase *database, yyjson_val *payload; yyjson_mut_val *copied_payload; - if (sqlite3_prepare_v2(database->connection, - "SELECT session_id,session_type,started_at,state,revision_id,payload_json FROM execution_drafts WHERE session_id=?1", - -1, &statement, NULL) != SQLITE_OK || + if (sqlite3_prepare_v2( + database->connection, + "SELECT session_id,session_type,started_at,state,revision_id,payload_json FROM " + "execution_drafts WHERE session_id=?1", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { if (statement != NULL) { @@ -568,9 +646,8 @@ static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) { strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U; } -static bool json_object_has_only(yyjson_val *object, - const char *const *allowed, - size_t allowed_count) { +static bool +json_object_has_only(yyjson_val *object, const char *const *allowed, size_t allowed_count) { yyjson_obj_iter iterator = yyjson_obj_iter_with(object); yyjson_val *key; @@ -611,22 +688,37 @@ static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *v if (value == NULL || yyjson_is_null(value)) { return sqlite3_bind_null(statement, index) == SQLITE_OK; } - return yyjson_is_str(value) && - sqlite3_bind_text(statement, index, yyjson_get_str(value), - (int)yyjson_get_len(value), SQLITE_TRANSIENT) == SQLITE_OK; + return yyjson_is_str(value) && sqlite3_bind_text(statement, + index, + yyjson_get_str(value), + (int)yyjson_get_len(value), + SQLITE_TRANSIENT) == SQLITE_OK; } static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database, const char *revision_id, yyjson_val *entry, size_t position) { - static const char *const ALLOWED[] = { - "entry_id", "exercise_id", "equipment_id", "load_mode", "rest_seconds", - "target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "notes"}; + static const char *const ALLOWED[] = {"entry_id", + "exercise_id", + "equipment_id", + "load_mode", + "rest_seconds", + "target_sets", + "target_reps", + "target_duration_seconds", + "target_weight_kg", + "notes"}; static const char INSERT_SQL[] = - "INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id,equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) " - "SELECT ?1,?2,?3,e.exercise_id,?5,e.recording_mode,e.tracking_mode,e.data_fields,?6,?7,?8,?9,?10,?11,?12 FROM exercises e " - "WHERE e.exercise_id=?4 AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1)"; + "INSERT INTO " + "session_preparation_entries(revision_id,entry_id,position,exercise_id,equipment_id," + "recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,target_reps," + "target_duration_seconds,target_weight_kg,notes) " + "SELECT " + "?1,?2,?3,e.exercise_id,?5,e.recording_mode,e.tracking_mode,e.data_fields,?6,?7,?8,?9,?10,?" + "11,?12 FROM exercises e " + "WHERE e.exercise_id=?4 AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " + "c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1)"; sqlite3_stmt *statement = NULL; const char *entry_id; const char *exercise_id; @@ -638,8 +730,8 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database, if (!yyjson_is_obj(entry) || !json_object_has_only(entry, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) || - !bounded_json_string(yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U, - &exercise_id) || + !bounded_json_string( + yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U, &exercise_id) || !bounded_json_string(yyjson_obj_get(entry, "load_mode"), 16U, &load_mode) || (strcmp(load_mode, "none") != 0 && strcmp(load_mode, "external") != 0 && strcmp(load_mode, "assistance") != 0)) { @@ -684,14 +776,16 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database, value = yyjson_obj_get(entry, "target_reps"); if (value == NULL || yyjson_is_null(value)) { (void)sqlite3_bind_null(statement, 9); - } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 10000 || + } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || + yyjson_get_sint(value) > 10000 || sqlite3_bind_int64(statement, 9, yyjson_get_sint(value)) != SQLITE_OK) { goto invalid; } value = yyjson_obj_get(entry, "target_duration_seconds"); if (value == NULL || yyjson_is_null(value)) { (void)sqlite3_bind_null(statement, 10); - } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 604800 || + } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || + yyjson_get_sint(value) > 604800 || sqlite3_bind_int64(statement, 10, yyjson_get_sint(value)) != SQLITE_OK) { goto invalid; } @@ -725,9 +819,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, size_t save_body_size, char **output_json, size_t *output_size) { - static const char *const ALLOWED[] = { - "title", "session_type", "planned_for", "notes", "editing_state", "occurrences", - "source_proposal_id", "source_payload_sha256"}; + static const char *const ALLOWED[] = {"title", + "session_type", + "planned_for", + "notes", + "editing_state", + "occurrences", + "source_proposal_id", + "source_payload_sha256"}; yyjson_doc *input = NULL; yyjson_val *root; yyjson_val *occurrences; @@ -746,9 +845,9 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, yyjson_mut_doc *response; yyjson_mut_val *response_root; - if (database == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' || - save_body == NULL || save_body_size == 0U || output_json == NULL || output_size == NULL || - !timestamp_now(now)) { + if (database == NULL || expected_revision == NULL || request_id == NULL || + request_id[0] == '\0' || save_body == NULL || save_body_size == 0U || output_json == NULL || + output_size == NULL || !timestamp_now(now)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; @@ -767,17 +866,16 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, } occurrences = yyjson_obj_get(root, "occurrences"); if (!yyjson_is_arr(occurrences) || yyjson_arr_size(occurrences) > 64U || - (strcmp(editing_state, "ready") == 0 && (title[0] == '\0' || yyjson_arr_size(occurrences) == 0U))) { + (strcmp(editing_state, "ready") == 0 && + (title[0] == '\0' || yyjson_arr_size(occurrences) == 0U))) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } if (yyjson_obj_get(root, "source_proposal_id") != NULL && - (!bounded_json_string(yyjson_obj_get(root, "source_proposal_id"), - TRAINLOG_ID_MAX + 1U, - &source_proposal) || - !bounded_json_string(yyjson_obj_get(root, "source_payload_sha256"), - 65U, - &source_fingerprint) || + (!bounded_json_string( + yyjson_obj_get(root, "source_proposal_id"), TRAINLOG_ID_MAX + 1U, &source_proposal) || + !bounded_json_string( + yyjson_obj_get(root, "source_payload_sha256"), 65U, &source_fingerprint) || strlen(source_fingerprint) != 64U)) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; @@ -788,7 +886,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, } if (actual_preparation == NULL || actual_preparation[0] == '\0') { if (expected_revision[0] != '\0' || - trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) != TRAINLOG_STATUS_OK) { + trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) != + TRAINLOG_STATUS_OK) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -801,9 +900,12 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, } /* INVARIANT: a replayed request returns its committed identity and does * not evaluate the now-stale expected revision again. */ - if (sqlite3_prepare_v2(database->connection, - "SELECT response_json FROM session_preparation_requests WHERE request_id=?1", - -1, &statement, NULL) == SQLITE_OK && + if (sqlite3_prepare_v2( + database->connection, + "SELECT response_json FROM session_preparation_requests WHERE request_id=?1", + -1, + &statement, + NULL) == SQLITE_OK && sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && sqlite3_step(statement) == SQLITE_ROW) { const char *saved = (const char *)sqlite3_column_text(statement, 0); @@ -819,12 +921,19 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, statement = NULL; } if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) { - if (sqlite3_prepare_v2(database->connection, - "UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=?1 AND i.payload_sha256=?2)", - -1, &statement, NULL) != SQLITE_OK || + if (sqlite3_prepare_v2( + database->connection, + "UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 " + "WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE " + "i.draft_id=?1 AND i.payload_sha256=?2)", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK || - sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != SQLITE_OK || - sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != + SQLITE_OK || + sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) != + SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) { status = TRAINLOG_STATUS_CONFLICT; goto rollback; @@ -834,10 +943,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, } if (preparation_id != NULL && preparation_id[0] != '\0') { const char *current = NULL; - if (sqlite3_prepare_v2(database->connection, - "SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1", - -1, &statement, NULL) != SQLITE_OK || - sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || + if (sqlite3_prepare_v2( + database->connection, + "SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1", + -1, + &statement, + NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != + SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { status = TRAINLOG_STATUS_NOT_FOUND; goto rollback; @@ -849,10 +962,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, } (void)sqlite3_finalize(statement); statement = NULL; - } else if (sqlite3_prepare_v2(database->connection, - "INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)", - -1, &statement, NULL) != SQLITE_OK || - sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || + } else if (sqlite3_prepare_v2( + database->connection, + "INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)", + -1, + &statement, + NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != + SQLITE_OK || sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK || @@ -864,13 +981,18 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, (void)sqlite3_finalize(statement); statement = NULL; } - if (sqlite3_prepare_v2(database->connection, - "INSERT INTO session_preparation_revisions VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", - -1, &statement, NULL) != SQLITE_OK || + if (sqlite3_prepare_v2( + database->connection, + "INSERT INTO session_preparation_revisions VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || - (expected_revision[0] == '\0' ? sqlite3_bind_null(statement, 3) - : sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT)) != SQLITE_OK || + (expected_revision[0] == '\0' + ? sqlite3_bind_null(statement, 3) + : sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT)) != + SQLITE_OK || sqlite3_bind_text(statement, 4, title, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, session_type, -1, SQLITE_TRANSIENT) != SQLITE_OK || !bind_nullable_text(statement, 6, yyjson_obj_get(root, "planned_for")) || @@ -883,20 +1005,26 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, (void)sqlite3_finalize(statement); statement = NULL; for (index = 0U; index < yyjson_arr_size(occurrences); ++index) { - status = insert_preparation_entry( - database, revision, yyjson_arr_get(occurrences, index), index); + status = + insert_preparation_entry(database, revision, yyjson_arr_get(occurrences, index), index); if (status != TRAINLOG_STATUS_OK) { goto rollback; } } if (preparation_id != NULL && preparation_id[0] != '\0') { if (sqlite3_prepare_v2(database->connection, - "UPDATE session_preparations SET current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' END WHERE preparation_id=?4 AND current_revision_id=?5", - -1, &statement, NULL) != SQLITE_OK || + "UPDATE session_preparations SET " + "current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_" + "state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' " + "END WHERE preparation_id=?4 AND current_revision_id=?5", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK || - sqlite3_bind_text(statement, 4, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 4, actual_preparation, -1, SQLITE_TRANSIENT) != + SQLITE_OK || sqlite3_bind_text(statement, 5, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) { status = TRAINLOG_STATUS_CONFLICT; @@ -919,8 +1047,10 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, status = write_document(response, output_json, output_size); if (status != TRAINLOG_STATUS_OK || sqlite3_prepare_v2(database->connection, - "INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)", - -1, &statement, NULL) != SQLITE_OK || + "INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || @@ -974,8 +1104,11 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, return TRAINLOG_STATUS_DATABASE_ERROR; } if (sqlite3_prepare_v2(database->connection, - "SELECT response_json FROM session_preparation_requests WHERE request_id=?1 AND command='deliver'", - -1, &statement, NULL) == SQLITE_OK && + "SELECT response_json FROM session_preparation_requests WHERE " + "request_id=?1 AND command='deliver'", + -1, + &statement, + NULL) == SQLITE_OK && sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && sqlite3_step(statement) == SQLITE_ROW) { const char *saved = (const char *)sqlite3_column_text(statement, 0); @@ -989,9 +1122,14 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, (void)sqlite3_finalize(statement); statement = NULL; } - if (sqlite3_prepare_v2(database->connection, - "SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM session_preparation_entries e WHERE e.revision_id=p.current_revision_id)", - -1, &statement, NULL) != SQLITE_OK || + if (sqlite3_prepare_v2( + database->connection, + "SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND " + "p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM " + "session_preparation_entries e WHERE e.revision_id=p.current_revision_id)", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { @@ -1000,9 +1138,12 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, } (void)sqlite3_finalize(statement); statement = NULL; - if (sqlite3_prepare_v2(database->connection, - "INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)", - -1, &statement, NULL) != SQLITE_OK || + if (sqlite3_prepare_v2( + database->connection, + "INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, delivery_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || @@ -1027,9 +1168,12 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, status = write_document(response, output_json, output_size); response = NULL; if (status != TRAINLOG_STATUS_OK || - sqlite3_prepare_v2(database->connection, - "INSERT INTO session_preparation_requests VALUES(?1,'deliver',?2,?3,?4,?5)", - -1, &statement, NULL) != SQLITE_OK || + sqlite3_prepare_v2( + database->connection, + "INSERT INTO session_preparation_requests VALUES(?1,'deliver',?2,?3,?4,?5)", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || @@ -1041,8 +1185,11 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, } statement = NULL; if (sqlite3_prepare_v2(database->connection, - "UPDATE session_preparations SET delivery_state='pending' WHERE preparation_id=?1 AND current_revision_id=?2", - -1, &statement, NULL) != SQLITE_OK || + "UPDATE session_preparations SET delivery_state='pending' WHERE " + "preparation_id=?1 AND current_revision_id=?2", + -1, + &statement, + NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 || diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 9e72e77..cb55fea 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -382,6 +382,13 @@ static bool test_http_contract(TrainlogDatabase *database) { exchange( port, "GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) && strstr(response, "Trainlog") != NULL); + CHECK(exchange(port, + "GET /seances/preparation/sp_11111111-1111-4111-8111-111111111111 " + "HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "Trainlog") != NULL); CHECK(exchange(port, "GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, diff --git a/tui/tests/test_web_sessions.c b/tui/tests/test_web_sessions.c index 5d43fa0..f6efa7c 100644 --- a/tui/tests/test_web_sessions.c +++ b/tui/tests/test_web_sessions.c @@ -9,12 +9,12 @@ #include "trainlog/database.h" #include "trainlog/web_sessions.h" -#define CHECK(expression) \ - do { \ - if (!(expression)) { \ +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \ - return false; \ - } \ + return false; \ + } \ } while (0) static int scalar(TrainlogDatabase *database, const char *sql) { @@ -45,8 +45,7 @@ static bool creation_replay_revision_and_pagination(void) { "\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\"," "\"occurrences\":[]}"; TrainlogDatabase *database = NULL; - TrainlogWebSessionsPageQuery query = { - TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"}; + TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"}; char *created = NULL; char *replayed = NULL; char *detail = NULL; diff --git a/web/src/api/sessions.test.ts b/web/src/api/sessions.test.ts new file mode 100644 index 0000000..c26df0f --- /dev/null +++ b/web/src/api/sessions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { fetchAllSessionPages } from './sessions' + +function item(identity: string) { + return { + identity, + title: identity, + date: '2026-09-17', + state: 'draft', + occurrence_count: 1, + sort_timestamp: '2026-09-17T12:00:00Z', + } +} + +describe('Sessions pagination', () => { + it('loads every page without losing an item at the boundary', async () => { + const first = Array.from({ length: 24 }, (_, index) => item(`sp_${index}`)) + const second = Array.from({ length: 17 }, (_, index) => item(`sp_${index + 24}`)) + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ + api_version: 1, kind: 'preparations', offset: 0, more: true, next_offset: 24, items: first, + }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ + api_version: 1, kind: 'preparations', offset: 24, more: false, next_offset: 41, items: second, + }) })) + + const result = await fetchAllSessionPages('preparations') + expect(result).toHaveLength(41) + expect(result.at(-1)?.identity).toBe('sp_40') + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('rejects a stale or looping page instead of merging it', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ + api_version: 1, kind: 'preparations', offset: 0, more: true, next_offset: 0, items: [item('sp_one')], + }) })) + await expect(fetchAllSessionPages('preparations')).rejects.toThrow('curseur') + }) +}) diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts index 2c96a00..ba5d94f 100644 --- a/web/src/api/sessions.ts +++ b/web/src/api/sessions.ts @@ -100,6 +100,31 @@ export async function fetchSessionPage( return value as unknown as SessionPage } +export async function fetchAllSessionPages( + collection: SessionCollection, + search = '', + signal?: AbortSignal, +): Promise { + const items: SessionListItem[] = [] + const identities = new Set() + let offset = 0 + + for (;;) { + const page = await fetchSessionPage(collection, offset, search, signal) + if (page.offset !== offset) throw new TypeError('curseur de séances obsolète') + for (const item of page.items) { + if (identities.has(item.identity)) throw new TypeError('pagination de séances incohérente') + identities.add(item.identity) + items.push(item) + } + if (!page.more) return items + if (page.next_offset <= offset || page.items.length === 0) { + throw new TypeError('curseur de séances obsolète') + } + offset = page.next_offset + } +} + export async function fetchSessionDetail( kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal, ): Promise { diff --git a/web/src/routes/SessionsPage.test.tsx b/web/src/routes/SessionsPage.test.tsx new file mode 100644 index 0000000..d6aed45 --- /dev/null +++ b/web/src/routes/SessionsPage.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SessionsPage } from './SessionsPage' + +function page(kind: string, items: unknown[]) { + return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items } +} + +function response(value: unknown) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(value) }) +} + +describe('Sessions page', () => { + it('keeps proposals distinct and opens a stable deep link', async () => { + window.history.replaceState(null, '', '/seances') + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/preparations?')) return response(page('preparations', [])) + if (url.includes('/proposals?')) return response(page('proposals', [{ + identity: 'aid_target', title: 'Proposition cible', date: '2026-09-17', state: 'published', + occurrence_count: 6, sort_timestamp: '2026-09-17T12:00:00Z', + }])) + return response({ api_version: 1, kind: 'proposal', identity: 'aid_target', revision_id: 'rev', + title: 'Proposition cible', planned_for: null, session_type: 'training', notes: null, + source_fingerprint: 'abc', state: 'published', occurrences: [] }) + })) + render() + await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument()) + expect(screen.getByText('Proposition IA')).toBeInTheDocument() + fireEvent.click(screen.getByText('Proposition cible')) + expect(window.location.pathname).toBe('/seances/proposal/aid_target') + await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument()) + }) + + it('reports an API failure instead of presenting an empty list', async () => { + window.history.replaceState(null, '', '/seances') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network'))) + render() + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles')) + expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx index 7708fb1..8e3fc06 100644 --- a/web/src/routes/SessionsPage.tsx +++ b/web/src/routes/SessionsPage.tsx @@ -4,7 +4,7 @@ import { fetchSessionDetail, fetchReadonlySessionDetail, prepareForAndroid, - fetchSessionPage, + fetchAllSessionPages, savePreparation, type CatalogChoice, type PreparationInput, @@ -214,14 +214,17 @@ export function SessionsPage() { const [view, setView] = useState('preparation') const [items, setItems] = useState>([]) const [search, setSearch] = useState('') + const [stateFilter, setStateFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [pending, setPending] = useState(true) const [failed, setFailed] = useState(false) const [editor, setEditor] = useState(null) const [detailPath, setDetailPath] = useState(() => window.location.pathname) const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath]) const load = () => { const controller = new AbortController(); setPending(true); setFailed(false) - Promise.all(collections[view].map((collection) => fetchSessionPage(collection, 0, search, controller.signal) - .then((page) => page.items.map((item) => ({ ...item, collection }))))) + Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal) + .then((pageItems) => pageItems.map((item) => ({ ...item, collection }))))) .then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false)) return controller } useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search]) @@ -234,6 +237,13 @@ export function SessionsPage() { window.history.pushState(null, '', path); setDetailPath(path) } const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) } + const visibleItems = useMemo(() => items.filter((item) => { + if (stateFilter && item.state !== stateFilter) return false + if (dateFrom && (!item.date || item.date < dateFrom)) return false + if (dateTo && (!item.date || item.date > dateTo)) return false + return true + }), [items, stateFilter, dateFrom, dateTo]) + const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items]) if (editor) return
setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} />
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) { @@ -253,9 +263,16 @@ export function SessionsPage() { {view === 'preparation' && } +
+ + + +
{pending &&

Chargement…

}{failed &&

Les données ne sont pas disponibles.

} - {!pending && !failed && items.length === 0 &&

Aucun résultat pour cette vue.

} -
{items.map((item) =>