Complete Web Sessions pagination and browser flow
This commit is contained in:
parent
e0a0397d2a
commit
bbcb487491
12 changed files with 573 additions and 212 deletions
111
tests/test_web_sessions_browser.py
Normal file
111
tests/test_web_sessions_browser.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
33
tui/src/id.c
33
tui/src/id.c
|
|
@ -11,30 +11,18 @@
|
|||
|
||||
#include <uuid/uuid.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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, "<title>Trainlog</title>") != 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, "<title>Trainlog</title>") != NULL);
|
||||
CHECK(exchange(port,
|
||||
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||
response,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
39
web/src/api/sessions.test.ts
Normal file
39
web/src/api/sessions.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -100,6 +100,31 @@ export async function fetchSessionPage(
|
|||
return value as unknown as SessionPage
|
||||
}
|
||||
|
||||
export async function fetchAllSessionPages(
|
||||
collection: SessionCollection,
|
||||
search = '',
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionListItem[]> {
|
||||
const items: SessionListItem[] = []
|
||||
const identities = new Set<string>()
|
||||
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<SessionDetail> {
|
||||
|
|
|
|||
42
web/src/routes/SessionsPage.test.tsx
Normal file
42
web/src/routes/SessionsPage.test.tsx
Normal file
|
|
@ -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(<SessionsPage />)
|
||||
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(<SessionsPage />)
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
|
||||
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<View>('preparation')
|
||||
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
|
||||
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<SessionDetail | 'new' | null>(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 <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
|
||||
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
|
||||
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
|
||||
|
|
@ -253,9 +263,16 @@ export function SessionsPage() {
|
|||
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
|
||||
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
|
||||
onChange={(event) => setSearch(event.target.value)} /></label>
|
||||
<div className="session-filters" aria-label="Filtres de séances">
|
||||
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
|
||||
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>{state}</option>)}
|
||||
</select></label>
|
||||
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
|
||||
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
|
||||
</div>
|
||||
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
|
||||
{!pending && !failed && items.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
||||
<div className="session-list">{items.map((item) => <button type="button" className="session-row"
|
||||
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
||||
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
|
||||
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
|
||||
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
|
||||
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
||||
.quiet-action { border-color: var(--surface-2); }
|
||||
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
|
||||
.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||
.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
|
||||
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
|
||||
.session-list { display: grid; gap: .6rem; }
|
||||
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue