The read/write surface the dispatcher handlers need, so download.add persists and download.list / download.get project real rows when the engine lands. - store/tasks — TaskRow (1:1 with the schema), insert / get / remove / set_state / count, and list(filter, sort, offset, limit) that does all the WHERE / ORDER BY / LIMIT in SQL (M1 DoD: a 1000-row list never materialised client-side). Filter covers states / category / queue / case-insensitive filename+url substring / date range; sort is a whitelisted column + direction with NULLs last, default newest-first; the enum spellings in a state IN (...) come from proto::to_string, never from user text. to_summary() projects a row onto proto::TaskSummary including the flattened error block when the task failed / retry_wait / auto-paused. - store/settings — key -> JSON-text with a built-in default table mirroring Settings.schema.json / ADR 0012; get_raw / set_raw / overrides plus typed get_int / get_string / get_string_array for the governor config and saveTo.allowedRoots. Full settings.get/set wire projection lands with those handlers. - veloxd_store now links velox::proto + nlohmann_json for the projection. Test veloxd.store_tasks: insert/get round trip, PK duplicate rejected, the error-block projection, list total+paging+sort+every filter, set_state pause_reason clear-on-unpause, remove, and settings default vs override. ASan+UBSan and TSan clean; 34 daemon/cli tests green. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
188 lines
6.9 KiB
C++
188 lines
6.9 KiB
C++
// store/tasks + store/settings: insert / get / list (filter, sort, page) and the
|
|
// TaskSummary projection.
|
|
|
|
#include <string>
|
|
|
|
#include "check.hpp"
|
|
#include "store/migrations.hpp"
|
|
#include "store/settings.hpp"
|
|
#include "store/sqlite.hpp"
|
|
#include "store/tasks.hpp"
|
|
|
|
using namespace velox::daemon::store;
|
|
namespace proto = velox::proto;
|
|
|
|
namespace {
|
|
|
|
TaskRow row(std::string id, std::string name, std::string state, std::string created,
|
|
std::optional<std::int64_t> size = std::nullopt,
|
|
std::optional<std::string> cat = std::nullopt) {
|
|
TaskRow r;
|
|
r.task_id = std::move(id);
|
|
r.url = "https://example.com/" + name;
|
|
r.save_dir = "/home/u/Downloads";
|
|
r.filename = std::move(name);
|
|
r.state = std::move(state);
|
|
r.created_at = std::move(created);
|
|
r.size_bytes = size;
|
|
r.category_id = std::move(cat);
|
|
return r;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void run() {
|
|
auto db = Db::open(":memory:");
|
|
CHECK(db.has_value());
|
|
if (!db) return;
|
|
CHECK(migrate_to_head(*db).has_value());
|
|
|
|
Tasks tasks(*db);
|
|
|
|
// --- insert + get round trip ------------------------------------------------
|
|
{
|
|
auto r = row("t1", "ubuntu.iso", "downloading", "2026-09-10T10:00:00Z", 6228541440LL,
|
|
"programs");
|
|
r.downloaded_bytes = 1000000;
|
|
r.eff_segments = 8;
|
|
r.resumable = true;
|
|
CHECK(tasks.insert(r).has_value());
|
|
|
|
auto got = tasks.get("t1");
|
|
CHECK(got.has_value() && got->has_value());
|
|
if (got && *got) {
|
|
CHECK_EQ((*got)->filename, std::string("ubuntu.iso"));
|
|
CHECK_EQ((*got)->downloaded_bytes, 1000000);
|
|
CHECK((*got)->size_bytes.has_value() && *(*got)->size_bytes == 6228541440LL);
|
|
CHECK((*got)->resumable);
|
|
CHECK((*got)->category_id.value_or("") == "programs");
|
|
}
|
|
|
|
auto missing = tasks.get("nope");
|
|
CHECK(missing.has_value() && !missing->has_value());
|
|
}
|
|
|
|
// --- a duplicate id is rejected by the primary key -----------------------
|
|
CHECK(!tasks.insert(row("t1", "again", "new", "2026-09-10T10:01:00Z")).has_value());
|
|
|
|
// --- projection to TaskSummary, including the error block --------------
|
|
{
|
|
auto r = row("terr", "broken.zip", "failed", "2026-09-10T09:00:00Z");
|
|
r.error_code = "not_found";
|
|
r.error_message = "server returned 404";
|
|
r.error_http_status = 404;
|
|
r.error_retryable = false;
|
|
r.error_attempt = 3;
|
|
CHECK(tasks.insert(r).has_value());
|
|
|
|
auto got = tasks.get("terr");
|
|
CHECK(got && *got);
|
|
const auto s = to_summary(**got);
|
|
CHECK(s.state == proto::TaskState::Failed);
|
|
CHECK(s.error.has_value());
|
|
if (s.error) {
|
|
CHECK(s.error->code == proto::TaskErrorCode::NotFound);
|
|
CHECK(s.error->httpStatus.value_or(0) == 404);
|
|
CHECK(s.error->attempt.value_or(0) == 3);
|
|
}
|
|
// a non-error task has no error block
|
|
auto ok = tasks.get("t1");
|
|
CHECK(!to_summary(**ok).error.has_value());
|
|
}
|
|
|
|
// --- list: total + paging + default newest-first sort ------------------
|
|
{
|
|
for (int i = 0; i < 20; ++i) {
|
|
char id[8];
|
|
std::snprintf(id, sizeof(id), "p%02d", i);
|
|
char ts[24];
|
|
std::snprintf(ts, sizeof(ts), "2026-09-11T%02d:00:00Z", i);
|
|
CHECK(tasks.insert(row(id, std::string("f") + id, "queued", ts)).has_value());
|
|
}
|
|
auto page = tasks.list(std::nullopt, std::nullopt, 0, 5);
|
|
CHECK(page.has_value());
|
|
if (page) {
|
|
CHECK_EQ(page->total, 22); // 20 + t1 + terr
|
|
CHECK_EQ(page->rows.size(), 5u);
|
|
// newest first: p19 (11:00) before p18 ...
|
|
CHECK_EQ(page->rows.front().task_id, std::string("p19"));
|
|
}
|
|
auto page2 = tasks.list(std::nullopt, std::nullopt, 20, 500);
|
|
CHECK(page2.has_value());
|
|
if (page2) CHECK_EQ(page2->rows.size(), 2u); // the tail
|
|
}
|
|
|
|
// --- list: filter by state ---------------------------------------------
|
|
{
|
|
proto::TaskFilter f;
|
|
f.states = std::vector<proto::TaskState>{proto::TaskState::Queued};
|
|
auto page = tasks.list(f, std::nullopt, 0, 500);
|
|
CHECK(page.has_value());
|
|
if (page) {
|
|
CHECK_EQ(page->total, 20);
|
|
for (const auto& r : page->rows) CHECK_EQ(r.state, std::string("queued"));
|
|
}
|
|
}
|
|
|
|
// --- list: filter by category + case-insensitive query ---------------
|
|
{
|
|
proto::TaskFilter f;
|
|
f.categoryId = "programs";
|
|
auto page = tasks.list(f, std::nullopt, 0, 500);
|
|
CHECK(page.has_value() && page->total == 1);
|
|
|
|
proto::TaskFilter q;
|
|
q.query = "UBUNTU"; // matches "ubuntu.iso" case-insensitively
|
|
auto page2 = tasks.list(q, std::nullopt, 0, 500);
|
|
CHECK(page2.has_value() && page2->total == 1);
|
|
if (page2 && !page2->rows.empty())
|
|
CHECK_EQ(page2->rows.front().task_id, std::string("t1"));
|
|
}
|
|
|
|
// --- list: explicit sort by filename ascending ----------------------
|
|
{
|
|
proto::TaskSort s;
|
|
s.field = proto::TaskSortField::Filename;
|
|
s.direction = proto::TaskSortDirection::Asc;
|
|
auto page = tasks.list(std::nullopt, s, 0, 3);
|
|
CHECK(page.has_value());
|
|
if (page && page->rows.size() >= 2)
|
|
CHECK(page->rows[0].filename <= page->rows[1].filename);
|
|
}
|
|
|
|
// --- set_state + pause_reason, and remove -------------------------
|
|
{
|
|
CHECK(tasks.set_state("t1", "paused", std::string("user")).value_or(false));
|
|
auto g = tasks.get("t1");
|
|
CHECK(g && *g && (*g)->state == "paused" && (*g)->pause_reason.value_or("") == "user");
|
|
|
|
CHECK(tasks.set_state("t1", "downloading", std::nullopt).value_or(false));
|
|
g = tasks.get("t1");
|
|
CHECK(g && *g && !(*g)->pause_reason.has_value()); // cleared when not paused
|
|
|
|
CHECK(!tasks.set_state("ghost", "paused", std::nullopt).value_or(true));
|
|
|
|
CHECK(tasks.remove("terr").value_or(false));
|
|
CHECK(!tasks.remove("terr").value_or(true));
|
|
CHECK(tasks.count().value_or(-1) == 21);
|
|
}
|
|
|
|
// --- settings: default fallback, override, typed reads ---------------
|
|
{
|
|
Settings settings(*db);
|
|
CHECK_EQ(settings.get_int("connection.maxConcurrentDownloads"), 5); // built-in default
|
|
CHECK(settings.set_raw("connection.maxConcurrentDownloads", "9").has_value());
|
|
CHECK_EQ(settings.get_int("connection.maxConcurrentDownloads"), 9); // override wins
|
|
|
|
CHECK_EQ(settings.get_int("connection.maxActiveSegments"), 32);
|
|
auto roots = settings.get_string_array("saveTo.allowedRoots");
|
|
CHECK_EQ(roots.size(), 1u);
|
|
if (!roots.empty()) CHECK_EQ(roots.front(), std::string("~/Downloads"));
|
|
|
|
auto ov = settings.overrides();
|
|
CHECK(ov.has_value() && ov->count("connection.maxConcurrentDownloads") == 1);
|
|
}
|
|
}
|
|
|
|
TEST_MAIN()
|