From 380195f7cd5e57acf2c1cf2bc41069e6b0b979ed Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Thu, 17 Sep 2026 19:55:36 +0200 Subject: Initial commit: daemon, clients, docs, Docker deploy pipeline --- tests/test_core.c | 1088 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1088 insertions(+) create mode 100644 tests/test_core.c (limited to 'tests') 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 +#include +#include +#include +#include +#include + +#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; +} -- cgit v1.3