summaryrefslogtreecommitdiff
path: root/tests/test_core.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-17 19:55:36 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-17 19:55:36 +0200
commit380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (patch)
tree32a88fb22a7fbe8f1fd5c105156d1f928c93950d /tests/test_core.c
downloadbokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.tar.gz
bokf-380195f7cd5e57acf2c1cf2bc41069e6b0b979ed.zip
Initial commit: daemon, clients, docs, Docker deploy pipelinev0.1.0
Diffstat (limited to 'tests/test_core.c')
-rw-r--r--tests/test_core.c1088
1 files changed, 1088 insertions, 0 deletions
diff --git a/tests/test_core.c b/tests/test_core.c
new file mode 100644
index 0000000..db3bfb2
--- /dev/null
+++ b/tests/test_core.c
@@ -0,0 +1,1088 @@
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "config.h"
+#include "db.h"
+#include "protocol.h"
+#include "sessions.h"
+#include "util.h"
+#include "yyjson.h"
+
+static sqlite3 *g_db;
+static char g_session[128];
+static int g_failures;
+
+#define CHECK(cond) \
+ do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \
+ #cond); \
+ g_failures++; \
+ } \
+ } while (0)
+
+static char *reqf(const char *fmt, ...)
+ __attribute__((format(printf, 1, 2)));
+
+static char *reqf(const char *fmt, ...)
+{
+ static char buf[4096];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(buf, sizeof buf, fmt, ap);
+ va_end(ap);
+ return buf;
+}
+
+static yyjson_doc *call(const char *req)
+{
+ char *resp = protocol_handle_line(g_db, req, strlen(req));
+ if (!resp) {
+ CHECK(0 && "null response");
+ return NULL;
+ }
+ yyjson_doc *d = yyjson_read(resp, strlen(resp), 0);
+ free(resp);
+ return d;
+}
+
+static yyjson_val *jget(yyjson_doc *d, const char *path)
+{
+ if (!d)
+ return NULL;
+ char tmp[128];
+ snprintf(tmp, sizeof tmp, "%s", path);
+ yyjson_val *v = yyjson_doc_get_root(d);
+ char *save = NULL;
+ for (char *tok = strtok_r(tmp, ".", &save); tok;
+ tok = strtok_r(NULL, ".", &save)) {
+ if (!v)
+ return NULL;
+ if (yyjson_is_obj(v)) {
+ v = yyjson_obj_get(v, tok);
+ } else if (yyjson_is_arr(v)) {
+ char *end = NULL;
+ long idx = strtol(tok, &end, 10);
+ if (!end || *end)
+ return NULL;
+ v = yyjson_arr_get(v, (size_t)idx);
+ } else {
+ return NULL;
+ }
+ }
+ return v;
+}
+
+static const char *jstr(yyjson_doc *d, const char *path)
+{
+ yyjson_val *v = jget(d, path);
+ return v && yyjson_is_str(v) ? yyjson_get_str(v) : NULL;
+}
+
+static int64_t jint(yyjson_doc *d, const char *path)
+{
+ yyjson_val *v = jget(d, path);
+ return v && yyjson_is_int(v) ? yyjson_get_int(v) : -1;
+}
+
+static int jbool(yyjson_doc *d, const char *path)
+{
+ yyjson_val *v = jget(d, path);
+ return v && yyjson_is_bool(v) ? yyjson_get_bool(v) : 0;
+}
+
+static void check_str(yyjson_doc *d, const char *path, const char *expect,
+ int line)
+{
+ const char *got = jstr(d, path);
+ if (!got || strcmp(got, expect) != 0) {
+ fprintf(stderr, "FAIL %s:%d: %s = %s, expected %s\n", __FILE__, line,
+ path, got ? got : "(null)", expect);
+ g_failures++;
+ }
+}
+
+#define CHECK_STR(doc, path, expect) check_str(doc, path, expect, __LINE__)
+
+static void check_ok(yyjson_doc *d, int line)
+{
+ if (!jbool(d, "ok")) {
+ const char *code = jstr(d, "error.code");
+ fprintf(stderr, "FAIL %s:%d: not ok (%s)\n", __FILE__, line,
+ code ? code : "no code");
+ g_failures++;
+ }
+}
+
+#define CHECK_OK(doc) check_ok(doc, __LINE__)
+
+static int64_t find_amount(yyjson_doc *d, const char *array_path,
+ const char *key, const char *value,
+ const char *field)
+{
+ yyjson_val *arr = jget(d, array_path);
+ size_t n = arr && yyjson_is_arr(arr) ? yyjson_arr_size(arr) : 0;
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ yyjson_val *kv = yyjson_obj_get(row, key);
+ if (kv && yyjson_is_str(kv) &&
+ strcmp(yyjson_get_str(kv), value) == 0) {
+ yyjson_val *v = yyjson_obj_get(row, field);
+ return v && yyjson_is_int(v) ? yyjson_get_int(v) : -2;
+ }
+ }
+ return -1;
+}
+
+static int64_t vat_box(yyjson_doc *d, const char *box)
+{
+ return find_amount(d, "result.boxes", "box", box, "amount_ore");
+}
+
+static yyjson_doc *call_sie_import(const char *session, int64_t org,
+ const char *b64, int dry)
+{
+ size_t len = strlen(b64) + 1024;
+ char *req = xmalloc(len);
+ snprintf(req, len,
+ "{\"v\":1,\"id\":\"imp\",\"cmd\":\"sie.import\",\"session\":\"%s\","
+ "\"org\":%lld%s,\"args\":{\"content_base64\":\"%s\"}}",
+ session, (long long)org, dry ? ",\"dry_run\":true" : "", b64);
+ yyjson_doc *d = call(req);
+ free(req);
+ return d;
+}
+
+static int login(const char *user, const char *pass)
+{
+ yyjson_doc *d = call(reqf(
+ "{\"v\":1,\"id\":\"l\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"password\",\"username\":\"%s\",\"password\":\"%s\"}}",
+ user, pass));
+ int ok = jbool(d, "ok");
+ if (ok)
+ snprintf(g_session, sizeof g_session, "%s", jstr(d, "result.session"));
+ yyjson_doc_free(d);
+ return ok;
+}
+
+int main(void)
+{
+ char tmpdir[] = "/tmp/bokf-test-XXXXXX";
+ if (!mkdtemp(tmpdir)) {
+ perror("mkdtemp");
+ return 1;
+ }
+ char dbpath[512], backupdir[512];
+ snprintf(dbpath, sizeof dbpath, "%s/bokfd.db", tmpdir);
+ snprintf(backupdir, sizeof backupdir, "%s/backup", tmpdir);
+ mkdir(backupdir, 0700);
+
+ config_defaults();
+ g_cfg.log_level = 0;
+ free(g_cfg.backup_dir);
+ g_cfg.backup_dir = xstrdup(backupdir);
+ free(g_cfg.export_dir);
+ g_cfg.export_dir = xstrdup(tmpdir);
+ free(g_cfg.chart_k2_file);
+ g_cfg.chart_k2_file = xstrdup("data/bas_k2.csv");
+ free(g_cfg.chart_k3_file);
+ g_cfg.chart_k3_file = xstrdup("data/bas_k3.csv");
+
+ char *err = NULL;
+ if (db_open(dbpath, &g_db, &err) != 0) {
+ fprintf(stderr, "db_open: %s\n", err ? err : "?");
+ return 1;
+ }
+ int64_t admin_id = 0;
+ if (db_create_user(g_db, "admin", "Admin", "secret123", 1, &admin_id,
+ &err) != 0) {
+ fprintf(stderr, "create admin: %s\n", err ? err : "?");
+ return 1;
+ }
+ sessions_init(3600);
+
+ yyjson_doc *d;
+
+ d = call("{\"v\":1,\"id\":\"1\",\"cmd\":\"health\"}");
+ CHECK_OK(d);
+ CHECK_STR(d, "result.status", "ok");
+ yyjson_doc_free(d);
+
+ d = call("{\"v\":1,\"id\":\"2\",\"cmd\":\"meta\"}");
+ CHECK_OK(d);
+ CHECK(jint(d, "result.protocol") == 1);
+ CHECK_STR(d, "result.server", "bokfd");
+ yyjson_doc_free(d);
+
+ d = call("{\"v\":1,\"id\":\"3\",\"cmd\":\"nope\"}");
+ CHECK(!jbool(d, "ok"));
+ CHECK_STR(d, "error.code", "UNKNOWN_COMMAND");
+ yyjson_doc_free(d);
+
+ d = call("{\"v\":9,\"id\":\"4\",\"cmd\":\"health\"}");
+ CHECK_STR(d, "error.code", "UNSUPPORTED_VERSION");
+ yyjson_doc_free(d);
+
+ d = call("not json");
+ CHECK_STR(d, "error.code", "PARSE_ERROR");
+ yyjson_doc_free(d);
+
+ d = call("{\"v\":1,\"id\":\"5\",\"cmd\":\"describe\"}");
+ CHECK_STR(d, "error.code", "AUTH_REQUIRED");
+ yyjson_doc_free(d);
+
+ CHECK(!login("admin", "wrong"));
+ CHECK(!login("nobody", "wrong"));
+ CHECK(login("admin", "secret123"));
+
+ d = call(reqf("{\"v\":1,\"id\":\"6\",\"cmd\":\"session.whoami\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.user.username", "admin");
+ CHECK(jbool(d, "result.user.is_admin"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"7\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":"
+ "{\"name\":\"AB Ett\",\"org_nr\":\"5560123456\"}}",
+ g_session));
+ CHECK_OK(d);
+ int64_t org_id = jint(d, "result.id");
+ CHECK(org_id > 0);
+ CHECK_STR(d, "result.name", "AB Ett");
+ CHECK_STR(d, "result.org_nr", "5560123456");
+ CHECK(jint(d, "result.fiscal_year_id") > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"8\",\"cmd\":\"org.list\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ CHECK_STR(d, "result.items.0.role", "owner");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"9\",\"cmd\":\"session.use_org\","
+ "\"session\":\"%s\",\"args\":{\"org\":%d}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.role", "owner");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"10\",\"cmd\":\"org.get\","
+ "\"session\":\"%s\",\"org\":10}",
+ g_session));
+ CHECK_STR(d, "error.code", "ORG_FORBIDDEN");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"11\",\"cmd\":\"org.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.moms_period", "month");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"12\",\"cmd\":\"token.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"label\":\"test\",\"scopes\":[\"read\",\"write\"]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ char token_copy[128] = "";
+ snprintf(token_copy, sizeof token_copy, "%s", jstr(d, "result.token"));
+ CHECK(strncmp(token_copy, "bokf_", 5) == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"13\",\"cmd\":\"token.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"14\",\"cmd\":\"token.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"label\":\"ro\",\"scopes\":[\"read\"]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ char ro_token[128] = "";
+ snprintf(ro_token, sizeof ro_token, "%s", jstr(d, "result.token"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"15\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"token\",\"token\":\"%s\"}}",
+ token_copy));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.active_org") == org_id);
+ snprintf(g_session, sizeof g_session, "%s", jstr(d, "result.session"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"16\",\"cmd\":\"session.whoami\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_STR(d, "result.scopes", "read,write");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"17\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"token\",\"token\":\"%s\"}}",
+ ro_token));
+ CHECK_OK(d);
+ char ro_session[128] = "";
+ snprintf(ro_session, sizeof ro_session, "%s", jstr(d, "result.session"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"18\",\"cmd\":\"org.member_add\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"username\":\"bob\",\"role\":\"viewer\"}}",
+ ro_session, (int)org_id));
+ CHECK_STR(d, "error.code", "FORBIDDEN");
+ yyjson_doc_free(d);
+
+ CHECK(login("admin", "secret123"));
+
+ d = call(reqf("{\"v\":1,\"id\":\"19\",\"cmd\":\"user.create\","
+ "\"session\":\"%s\",\"args\":"
+ "{\"username\":\"bob\",\"password\":\"hunter22\","
+ "\"display_name\":\"Bob\"}}",
+ g_session));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"20\",\"cmd\":\"user.create\","
+ "\"session\":\"%s\",\"args\":"
+ "{\"username\":\"bob\",\"password\":\"hunter22\"}}",
+ g_session));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"21\",\"cmd\":\"org.member_add\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"username\":\"bob\",\"role\":\"viewer\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"22\",\"cmd\":\"org.member_list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"23\",\"cmd\":\"org.member_set_role\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"username\":\"bob\",\"role\":\"bookkeeper\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"24\",\"cmd\":\"org.member_remove\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"username\":\"admin\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"25\",\"cmd\":\"org.member_remove\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"username\":\"bob\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"26\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.ok"));
+ CHECK(jint(d, "result.checked") > 5);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"27\",\"cmd\":\"audit.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) > 0);
+ yyjson_doc_free(d);
+
+ err = NULL;
+ CHECK(db_exec(g_db, "UPDATE audit_log SET action='x'", &err) != 0);
+ free(err);
+ err = NULL;
+ CHECK(db_exec(g_db, "DELETE FROM audit_log", &err) != 0);
+ free(err);
+
+ d = call(reqf("{\"v\":1,\"id\":\"28\",\"cmd\":\"backup.snapshot\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.size") > 0);
+ CHECK(jstr(d, "result.sha256") && strlen(jstr(d, "result.sha256")) == 64);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"29\",\"cmd\":\"describe\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.commands")) > 10);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"30\",\"cmd\":\"describe\","
+ "\"session\":\"%s\",\"args\":{\"cmd\":\"session.open\"}}",
+ g_session));
+ CHECK_STR(d, "result.command.name", "session.open");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"31\",\"cmd\":\"agent.instructions\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jstr(d, "result.content") &&
+ strstr(jstr(d, "result.content"), "dry_run") != NULL);
+ yyjson_doc_free(d);
+
+ /* ---------------- M2: kontoplan, vouchers, reports, SIE ---------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"40\",\"cmd\":\"account.list\","
+ "\"session\":\"%s\",\"org\":%d,"
+ "\"args\":{\"active_only\":true}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) > 1000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"41\",\"cmd\":\"account.get\","
+ "\"session\":\"%s\",\"org\":%d,"
+ "\"args\":{\"number\":\"1930\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.type", "asset");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"42\",\"cmd\":\"account.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"number\":\"0099\",\"name\":\"Testkonto\","
+ "\"type\":\"expense\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t acc2999 = jint(d, "result.id");
+ CHECK(acc2999 > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"43\",\"cmd\":\"account.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"number\":\"0099\",\"name\":\"Dup\","
+ "\"type\":\"expense\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "CONFLICT");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"44\",\"cmd\":\"account.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"id\":%lld,\"name\":\"Testkonto 2\"}}",
+ g_session, (int)org_id, (long long)acc2999));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"45\",\"cmd\":\"account.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)acc2999));
+ CHECK_STR(d, "result.name", "Testkonto 2");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"46\",\"cmd\":\"fiscal_year.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ int64_t fy1 = jint(d, "result.items.0.id");
+ CHECK(fy1 > 0);
+ yyjson_doc_free(d);
+
+ /* sale: 1250.00 kr incl 25% moms */
+ d = call(reqf("{\"v\":1,\"id\":\"47\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-15\",\"description\":\"Försäljning\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":125000},"
+ "{\"account\":\"3001\",\"credit_ore\":100000},"
+ "{\"account\":\"2611\",\"credit_ore\":25000}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.number") == 1);
+ int64_t sale_id = jint(d, "result.id");
+ CHECK(sale_id > 0);
+ CHECK(jstr(d, "result.hash") && strlen(jstr(d, "result.hash")) == 64);
+ CHECK(yyjson_arr_size(jget(d, "result.rows")) == 3);
+ yyjson_doc_free(d);
+
+ /* dry run must not persist */
+ d = call(reqf("{\"v\":1,\"id\":\"48\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"date\":\"2026-01-16\",\"description\":"
+ "\"Torrkörning\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"49\",\"cmd\":\"voucher.list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ yyjson_doc_free(d);
+
+ /* invalid vouchers */
+ d = call(reqf("{\"v\":1,\"id\":\"50\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-17\",\"description\":\"Obalans\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":90}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "UNBALANCED");
+ CHECK(jint(d, "error.details.difference_ore") == 10);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"51\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-17\",\"description\":\"Fel konto\",\"rows\":["
+ "{\"account\":\"9999\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "ACCOUNT_NOT_FOUND");
+ yyjson_doc_free(d);
+
+ /* reports based on the single sale */
+ d = call(reqf("{\"v\":1,\"id\":\"52\",\"cmd\":\"report.income_statement\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.result_ore") == 100000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"53\",\"cmd\":\"report.vat\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(vat_box(d, "05") == 100000);
+ CHECK(vat_box(d, "10") == 25000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"54\",\"cmd\":\"report.trial_balance\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(find_amount(d, "result.accounts", "account", "1930", "ub_ore") ==
+ 125000);
+ CHECK(jint(d, "result.totals.debit_ore") ==
+ jint(d, "result.totals.credit_ore"));
+ yyjson_doc_free(d);
+
+ /* idempotency */
+ d = call(reqf("{\"v\":1,\"id\":\"55\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-20\",\"description\":\"Faktura 2\","
+ "\"client_ref\":\"ref-1\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":50000},"
+ "{\"account\":\"3001\",\"credit_ore\":50000}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t v2 = jint(d, "result.id");
+ CHECK(!jbool(d, "result.replayed"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"56\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-20\",\"description\":\"Faktura 2\","
+ "\"client_ref\":\"ref-1\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":50000},"
+ "{\"account\":\"3001\",\"credit_ore\":50000}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.replayed"));
+ CHECK(jint(d, "result.id") == v2);
+ yyjson_doc_free(d);
+
+ /* attachments */
+ d = call(reqf("{\"v\":1,\"id\":\"57\",\"cmd\":\"attachment.put\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"filename\":"
+ "\"kvitto.txt\",\"mime\":\"text/plain\","
+ "\"content_base64\":\"aGVsbG8gd29ybGQ=\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t att_id = jint(d, "result.id");
+ CHECK(att_id > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"58\",\"cmd\":\"attachment.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"unlinked\":true}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"59\",\"cmd\":\"attachment.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)att_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.content_base64", "aGVsbG8gd29ybGQ=");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"60\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-01-25\",\"description\":\"Lokalhyra\","
+ "\"attachment_ids\":[%lld],\"rows\":["
+ "{\"account\":\"5010\",\"debit_ore\":10000},"
+ "{\"account\":\"1930\",\"credit_ore\":10000}]}}",
+ g_session, (int)org_id, (long long)att_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.attachment_ids")) == 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"61\",\"cmd\":\"attachment.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"unlinked\":true}}",
+ g_session, (int)org_id));
+ CHECK(yyjson_arr_size(jget(d, "result.items")) == 0);
+ yyjson_doc_free(d);
+
+ /* correction */
+ d = call(reqf("{\"v\":1,\"id\":\"62\",\"cmd\":\"voucher.correct\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"voucher\":%lld,"
+ "\"description\":\"Rättelse felaktig försäljning\","
+ "\"client_ref\":\"ref-3\"}}",
+ g_session, (int)org_id, (long long)sale_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.corrects_voucher_id") == sale_id);
+ int64_t corr_id = jint(d, "result.id");
+ CHECK(corr_id > sale_id);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"63\",\"cmd\":\"voucher.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)sale_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.rows")) == 3);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"64\",\"cmd\":\"report.vat\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(vat_box(d, "05") == 50000);
+ CHECK(vat_box(d, "10") == 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"65\",\"cmd\":\"report.income_statement\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.result_ore") == 40000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"66\",\"cmd\":\"report.balance_sheet\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(find_amount(d, "result.assets.accounts", "account", "1930",
+ "amount_ore") == 40000);
+ CHECK(jint(d, "result.assets.total_ore") ==
+ jint(d, "result.equity.total_ore"));
+ yyjson_doc_free(d);
+
+ /* attachment size limit is enforced by the handler */
+ {
+ char big[600];
+ memset(big, 'A', 500);
+ big[500] = '\0';
+ g_cfg.max_attachment_bytes = 100;
+ d = call(reqf("{\"v\":1,\"id\":\"76\",\"cmd\":\"attachment.put\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"filename\":\"big.bin\",\"mime\":\"application/octet-stream\","
+ "\"content_base64\":\"%s\"}}",
+ g_session, (int)org_id, big));
+ CHECK_STR(d, "error.code", "TOO_LARGE");
+ yyjson_doc_free(d);
+ g_cfg.max_attachment_bytes = 10 * 1024 * 1024;
+ }
+
+ /* ---------------- templates (konteringsmallar) ------------------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"80\",\"cmd\":\"template.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Testmall\","
+ "\"description\":\"Försäljning {x}\",\"rows\":["
+ "{\"account\":\"1930\",\"formula\":\"x\"},"
+ "{\"account\":\"3001\",\"formula\":\"-x/1.25\"},"
+ "{\"account\":\"2611\",\"formula\":\"-x/5\"}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t tpl_id = jint(d, "result.id");
+ CHECK(tpl_id > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"81\",\"cmd\":\"template.list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"active_only\":true}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.items")) >= 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"82\",\"cmd\":\"template.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Testmall\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.rows")) == 3);
+ CHECK_STR(d, "result.rows.0.formula", "x");
+ CHECK_STR(d, "result.rows.1.account", "3001");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"83\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"template\":\"Testmall\",\"x\":\"1250\","
+ "\"date\":\"2026-03-05\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.rows.0.debit_ore") == 125000);
+ CHECK(jint(d, "result.rows.1.credit_ore") == 100000);
+ CHECK(jint(d, "result.rows.2.credit_ore") == 25000);
+ CHECK_STR(d, "result.description", "Försäljning 1250.00");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"84\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"template\":\"Testmall\",\"x\":\"1250\","
+ "\"date\":\"2026-03-05\",\"client_ref\":\"tplref\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t tpl_voucher = jint(d, "result.id");
+ CHECK(tpl_voucher > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"85\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"template\":\"Testmall\",\"x\":\"1250\","
+ "\"date\":\"2026-03-05\",\"client_ref\":\"tplref\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.replayed"));
+ yyjson_doc_free(d);
+
+ /* rounding: four credits of x/4 do not add up to x in öre */
+ d = call(reqf("{\"v\":1,\"id\":\"86\",\"cmd\":\"template.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Rundning\",\"rows\":["
+ "{\"account\":\"1930\",\"formula\":\"x\"},"
+ "{\"account\":\"3001\",\"formula\":\"-x/4\"},"
+ "{\"account\":\"2611\",\"formula\":\"-x/4\"},"
+ "{\"account\":\"2641\",\"formula\":\"-x/4\"},"
+ "{\"account\":\"5011\",\"formula\":\"-x/4\"}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"87\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"template\":\"Rundning\",\"x\":\"0.03\","
+ "\"date\":\"2026-03-06\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.rows.0.debit_ore") == 4);
+ CHECK(jint(d, "result.rows.1.credit_ore") == 1);
+ CHECK(jint(d, "result.rows.4.credit_ore") == 1);
+ yyjson_doc_free(d);
+
+ /* invalid formula and unknown account are rejected at save time */
+ d = call(reqf("{\"v\":1,\"id\":\"88\",\"cmd\":\"template.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Fel1\","
+ "\"rows\":[{\"account\":\"1930\",\"formula\":\"x**\"},"
+ "{\"account\":\"3001\",\"formula\":\"-x\"}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"89\",\"cmd\":\"template.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Fel2\","
+ "\"rows\":[{\"account\":\"9999\",\"formula\":\"x\"},"
+ "{\"account\":\"3001\",\"formula\":\"-x\"}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "ACCOUNT_NOT_FOUND");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"90\",\"cmd\":\"template.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"name\":\"Testmall 2\"}}",
+ g_session, (int)org_id, (long long)tpl_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.name", "Testmall 2");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"91\",\"cmd\":\"template.archive\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Testmall 2\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"92\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"template\":\"Testmall 2\",\"x\":\"100\","
+ "\"date\":\"2026-03-05\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ /* ---------------- ingående balans (series IB) -------------------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"93\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":\"%s\","
+ "\"description\":\"Ingående balans\",\"series\":\"IB\","
+ "\"rows\":[{\"account\":\"1930\",\"debit_ore\":50000},"
+ "{\"account\":\"2010\",\"credit_ore\":50000}]}}",
+ g_session, (int)org_id, "2026-01-01"));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series", "IB");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"94\",\"cmd\":\"report.trial_balance\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") ==
+ 50000);
+ CHECK(find_amount(d, "result.accounts", "account", "2010", "ib_ore") ==
+ -50000);
+ yyjson_doc_free(d);
+
+ /* period lock */
+ d = call(reqf("{\"v\":1,\"id\":\"67\",\"cmd\":\"period.lock\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":"
+ "%lld,\"until\":\"2026-01-31\",\"reason\":\"bokslut\"}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"68\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"date\":\"2026-01-15\",\"description\":\"Låst\","
+ "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "PERIOD_LOCKED");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"69\",\"cmd\":\"period.unlock\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":"
+ "%lld,\"reason\":\"test\"}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* SIE export */
+ d = call(reqf("{\"v\":1,\"id\":\"70\",\"cmd\":\"sie.export\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":"
+ "%lld,\"inline\":true}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.size") > 0);
+ CHECK(jstr(d, "result.sha256") && strlen(jstr(d, "result.sha256")) == 64);
+ char sie_path[512] = "";
+ snprintf(sie_path, sizeof sie_path, "%s", jstr(d, "result.path"));
+ char *sie_b64 = xstrdup(jstr(d, "result.content_base64"));
+ yyjson_doc_free(d);
+
+ {
+ FILE *sf = fopen(sie_path, "rb");
+ CHECK(sf != NULL);
+ if (sf) {
+ char sbuf[65536];
+ size_t sn = fread(sbuf, 1, sizeof sbuf - 1, sf);
+ sbuf[sn] = '\0';
+ fclose(sf);
+ CHECK(strstr(sbuf, "#SIETYP 4") != NULL);
+ CHECK(strstr(sbuf, "#VER") != NULL);
+ CHECK(strstr(sbuf, "#TRANS 1930") != NULL);
+ }
+ }
+
+ /* SIE import into a second org */
+ d = call(reqf("{\"v\":1,\"id\":\"71\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":{\"name\":\"AB Två\"}}",
+ g_session));
+ CHECK_OK(d);
+ int64_t org2 = jint(d, "result.id");
+ CHECK(jint(d, "result.fiscal_year_id") > 0);
+ yyjson_doc_free(d);
+
+ d = call_sie_import(g_session, org2, sie_b64, 1);
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK(jint(d, "result.vouchers") >= 4);
+ yyjson_doc_free(d);
+
+ d = call_sie_import(g_session, org2, sie_b64, 0);
+ CHECK_OK(d);
+ CHECK(jint(d, "result.vouchers") >= 4);
+ yyjson_doc_free(d);
+ free(sie_b64);
+
+ d = call(reqf("{\"v\":1,\"id\":\"72\",\"cmd\":\"report.trial_balance\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org2));
+ CHECK_OK(d);
+ int64_t ub_org2 =
+ find_amount(d, "result.accounts", "account", "1930", "ub_ore");
+ int64_t ib_org2 =
+ find_amount(d, "result.accounts", "account", "1930", "ib_ore");
+ yyjson_doc_free(d);
+ CHECK(ib_org2 == 50000);
+
+ d = call(reqf("{\"v\":1,\"id\":\"72b\",\"cmd\":\"report.trial_balance\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ int64_t ub_org1 =
+ find_amount(d, "result.accounts", "account", "1930", "ub_ore");
+ yyjson_doc_free(d);
+ CHECK(ub_org1 > 0);
+ CHECK(ub_org1 == ub_org2);
+
+ d = call_sie_import(g_session, org2, "AAAA", 0);
+ CHECK(!jbool(d, "ok"));
+ yyjson_doc_free(d);
+
+ /* ---------------- settings: default series ----------------------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"95\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.default_series", "A");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"96\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"default_series\",\"value\":\"V-\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "V-");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"96b\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.default_series", "V-");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"97\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":\"%s\","
+ "\"description\":\"Serietest\",\"client_ref\":\"sertest\","
+ "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org_id, "2026-04-01"));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series", "V-");
+ CHECK(jint(d, "result.number") == 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"98\",\"cmd\":\"template.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"name\":\"Seriemall\",\"rows\":["
+ "{\"account\":\"1930\",\"formula\":\"x\"},"
+ "{\"account\":\"3001\",\"formula\":\"-x\"}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.series", "V-");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"99\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"default_series\",\"value\":\"123456789\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"100\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"nope\",\"value\":\"x\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "UNSUPPORTED");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"99b\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"attachment_dir\",\"value\":\"/tmp/bilagor\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "/tmp/bilagor");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"101\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.attachment_dir", "/tmp/bilagor");
+ yyjson_doc_free(d);
+
+ /* close fiscal year and verify hard stop */
+ d = call(reqf("{\"v\":1,\"id\":\"73\",\"cmd\":\"fiscal_year.close\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"confirm\":true}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"74\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"date\":\"2026-03-01\",\"description\":\"Stängt\","
+ "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "FISCAL_YEAR_CLOSED");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"75\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.ok"));
+ CHECK(jint(d, "result.checked") > 20);
+ yyjson_doc_free(d);
+
+ /* rate limiting must stay last: it blocks the login key */
+ for (int i = 0; i < 5; i++)
+ CHECK(!login("admin", "wrong"));
+ d = call("{\"v\":1,\"id\":\"32\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"password\",\"username\":\"admin\","
+ "\"password\":\"secret123\"}}");
+ CHECK_STR(d, "error.code", "RATE_LIMITED");
+ yyjson_doc_free(d);
+
+ sqlite3_close(g_db);
+ char path[600];
+ snprintf(path, sizeof path, "%s-wal", dbpath);
+ unlink(path);
+ snprintf(path, sizeof path, "%s-shm", dbpath);
+ unlink(path);
+ unlink(dbpath);
+ rmdir(backupdir);
+ rmdir(tmpdir);
+ config_free();
+
+ if (g_failures) {
+ fprintf(stderr, "%d check(s) failed\n", g_failures);
+ return 1;
+ }
+ printf("all tests passed\n");
+ return 0;
+}