summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-21 09:56:01 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-21 09:56:01 +0200
commit4488b6c2eb1a3ddf20e190ebd28953e4f3f01042 (patch)
treeb52812145147669d04f23a57d41b3f604104963f /tests
parente692f0fbe16342195297048534ad1be227493107 (diff)
downloadbokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.tar.gz
bokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.zip
payroll: lönebesked PDF and mail (schema v11)
Diffstat (limited to 'tests')
-rw-r--r--tests/test_core.c353
1 files changed, 353 insertions, 0 deletions
diff --git a/tests/test_core.c b/tests/test_core.c
index 945c308..12d42e5 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -724,6 +724,10 @@ static void test_pre_migration_snapshot(struct tctx *ctx)
" AND name='tax_table_meta'");
CHECK(v && strcmp(v, "tax_table_meta") == 0);
free(v);
+ v = db_text(db, "SELECT name FROM pragma_table_info('employees')"
+ " WHERE name='email'");
+ CHECK(v && strcmp(v, "email") == 0);
+ free(v);
int64_t user_id = 0;
CHECK(db_create_user(db, "migrated", "Migrated", "secret123", 0, &user_id,
&err) == 0);
@@ -4745,6 +4749,353 @@ static void test_payroll(struct tctx *t)
yyjson_doc_free(d);
}
+static void test_payslip(struct tctx *t)
+{
+ static const char hexkey[] =
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+ setenv("BOKFD_SECRET_KEY", hexkey, 1);
+ yyjson_doc *d;
+
+ int64_t anna_id = 0;
+ {
+ char sql[256];
+ snprintf(sql, sizeof sql,
+ "SELECT id FROM employees WHERE org_id=%d"
+ " AND name='Anna Anställd'",
+ (int)t->org_id);
+ char *v = db_text(g_db, sql);
+ CHECK(v != NULL);
+ if (v)
+ anna_id = strtoll(v, NULL, 10);
+ free(v);
+ }
+ CHECK(anna_id > 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"ps1\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"email\":\"anna@example.se\"}}",
+ g_session, (int)t->org_id, (long long)anna_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.email", "anna@example.se");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"ps2\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"email\":\"bad\\nmail\"}}",
+ g_session, (int)t->org_id, (long long)anna_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"ps3\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-07\",\"pay_date\":\"2026-07-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t run = jint(d, "result.id");
+ int64_t voucher = jint(d, "result.voucher_id");
+ CHECK(run > 0);
+ CHECK(voucher > 0);
+ yyjson_doc_free(d);
+
+ /* a run with two lines needs employee_id */
+ d = call(reqf("{\"v\":1,\"id\":\"ps4\",\"cmd\":\"payroll.payslip\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"ps5\",\"cmd\":\"payroll.payslip\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld,"
+ "\"employee_id\":999999}}",
+ g_session, (int)t->org_id, (long long)run));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
+ char req[512];
+ snprintf(req, sizeof req,
+ "{\"v\":1,\"id\":\"ps6\",\"cmd\":\"payroll.payslip\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld,"
+ "\"employee_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)run, (long long)anna_id);
+ char *raw = protocol_handle_line(g_db, req, strlen(req));
+ CHECK(raw != NULL);
+ CHECK(raw && strstr(raw, "19750505-1111") == NULL);
+ CHECK(raw && strstr(raw, "JVBERi0") != NULL);
+ yyjson_doc *rd = raw ? yyjson_read(raw, strlen(raw), 0) : NULL;
+ CHECK_OK(rd);
+ CHECK_STR(rd, "result.filename", "Lönebesked 2026-07 Anna Anställd.pdf");
+ const char *b64 = jstr(rd, "result.content_base64");
+ CHECK(b64 != NULL);
+ CHECK(b64 && strncmp(b64, "JVBERi0", 7) == 0);
+ if (b64) {
+ unsigned char *pdf = NULL;
+ size_t pdf_len = 0;
+ CHECK(util_b64_decode(b64, strlen(b64), &pdf, &pdf_len) == 0);
+ if (pdf) {
+ char *text = xmalloc(pdf_len + 1);
+ memcpy(text, pdf, pdf_len);
+ text[pdf_len] = '\0';
+ CHECK(strstr(text, "19750505-1111") == NULL);
+ CHECK(strstr(text, "********-1111") != NULL);
+ CHECK(strstr(text, "NEBESKED") != NULL);
+ CHECK(strstr(text, "rning 2026-07") != NULL);
+ CHECK(strstr(text, "-4 208,00") != NULL);
+ CHECK(strstr(text, "Arbetsgivaravgifter 31,42 % betalas av"
+ " arbetsgivaren.") != NULL);
+ free(text);
+ }
+ free(pdf);
+ }
+ yyjson_doc_free(rd);
+ free(raw);
+}
+
+static void test_payslip_mail(struct tctx *t)
+{
+ static const char hexkey[] =
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+ setenv("BOKFD_SECRET_KEY", hexkey, 1);
+ yyjson_doc *d;
+ char sql[512];
+
+ snprintf(sql, sizeof sql,
+ "SELECT id FROM employees WHERE org_id=%d"
+ " AND name='Anna Anställd'",
+ (int)t->org_id);
+ char *v = db_text(g_db, sql);
+ int64_t anna_id = v ? strtoll(v, NULL, 10) : 0;
+ free(v);
+ CHECK(anna_id > 0);
+ snprintf(sql, sizeof sql,
+ "SELECT id FROM employees WHERE org_id=%d"
+ " AND name='Bertil Bygg'",
+ (int)t->org_id);
+ v = db_text(g_db, sql);
+ int64_t bertil_id = v ? strtoll(v, NULL, 10) : 0;
+ free(v);
+ CHECK(bertil_id > 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"pm1\",\"cmd\":\"employee.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"email\":\"anna@example.se\"}}",
+ g_session, (int)t->org_id, (long long)anna_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* dry run: config is checked, the PDF rendered, nothing stored/sent */
+ d = call(reqf("{\"v\":1,\"id\":\"pm2\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-06\",\"pay_date\":\"2026-06-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t dry_run = jint(d, "result.id");
+ int64_t dry_voucher = jint(d, "result.voucher_id");
+ CHECK(dry_run > 0 && dry_voucher > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"pm3\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_host\",\"value\":\"127.0.0.1\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm4\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_from\",\"value\":\"loner@example.se\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm5\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_security\",\"value\":\"plain\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm6\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_user\",\"value\":\"loner@example.se\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm7\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"hemlig\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"pm8\",\"cmd\":\"payroll.payslip_mail\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"run_id\":%lld,\"employee_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)dry_run,
+ (long long)anna_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK_STR(d, "result.sent_to", "anna@example.se");
+ CHECK_STR(d, "result.filename",
+ "Lönebesked 2026-06 Anna Anställd.pdf");
+ yyjson_doc_free(d);
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM attachments WHERE org_id=%d"
+ " AND filename LIKE 'Lönebesked 2026-06%%'",
+ (int)t->org_id);
+ CHECK(db_count(g_db, sql) == 0);
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM voucher_attachments WHERE org_id=%d"
+ " AND voucher_id=%lld",
+ (int)t->org_id, (long long)dry_voucher);
+ CHECK(db_count(g_db, sql) == 0);
+
+ /* no e-mail on the employee */
+ d = call(reqf("{\"v\":1,\"id\":\"pm9\",\"cmd\":\"payroll.payslip_mail\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld,"
+ "\"employee_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)dry_run,
+ (long long)bertil_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ CHECK(jstr(d, "error.message") &&
+ strstr(jstr(d, "error.message"), "no e-mail") != NULL);
+ yyjson_doc_free(d);
+
+ /* a successful send stores and links the attachment */
+ char smtp_log[600];
+ snprintf(smtp_log, sizeof smtp_log, "%s/payslip-smtp.log", t->tmpdir);
+ unlink(smtp_log);
+ int smtp_port = 0;
+ pid_t smtp_pid = fake_smtp_start(&smtp_port, smtp_log, 0);
+ CHECK(smtp_pid > 0);
+ d = call(reqf("{\"v\":1,\"id\":\"pm10\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"%d\"}}",
+ g_session, (int)t->org_id, smtp_port));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"pm11\",\"cmd\":\"payroll.payslip_mail\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld,"
+ "\"employee_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)dry_run,
+ (long long)anna_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.sent_to", "anna@example.se");
+ CHECK_STR(d, "result.filename",
+ "Lönebesked 2026-06 Anna Anställd.pdf");
+ int64_t attachment_id = jint(d, "result.attachment_id");
+ CHECK(attachment_id > 0);
+ CHECK(jstr(d, "result.at") && *jstr(d, "result.at"));
+ yyjson_doc_free(d);
+ if (smtp_pid > 0)
+ waitpid(smtp_pid, NULL, 0);
+
+ {
+ FILE *lf = fopen(smtp_log, "rb");
+ CHECK(lf != NULL);
+ if (lf) {
+ char lbuf[131072];
+ size_t ln = fread(lbuf, 1, sizeof lbuf - 1, lf);
+ lbuf[ln] = '\0';
+ fclose(lf);
+ CHECK(strstr(lbuf, "Lönebesked") != NULL);
+ CHECK(strstr(lbuf, "application/pdf") != NULL);
+ CHECK(strstr(lbuf, "To: <anna@example.se>") != NULL);
+ CHECK(strstr(lbuf, "hemlig") == NULL);
+ }
+ }
+
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM voucher_attachments va JOIN attachments a"
+ " ON a.org_id=va.org_id AND a.id=va.attachment_id"
+ " WHERE va.org_id=%d AND va.voucher_id=%lld"
+ " AND a.id=%lld AND a.mime='application/pdf'"
+ " AND a.filename='Lönebesked 2026-06 Anna Anställd.pdf'",
+ (int)t->org_id, (long long)dry_voucher,
+ (long long)attachment_id);
+ CHECK(db_count(g_db, sql) == 1);
+ char *audit = db_text(g_db, "SELECT request_json FROM audit_log"
+ " WHERE action='payroll.payslip_mail'"
+ " ORDER BY seq DESC LIMIT 1");
+ CHECK(audit != NULL);
+ free(audit);
+
+ /* a refused recipient is SMTP_FAILED and the attachment stays linked */
+ d = call(reqf("{\"v\":1,\"id\":\"pm12\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-05\",\"pay_date\":\"2026-05-25\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ int64_t reject_run = jint(d, "result.id");
+ int64_t reject_voucher = jint(d, "result.voucher_id");
+ CHECK(reject_run > 0 && reject_voucher > 0);
+ yyjson_doc_free(d);
+
+ unlink(smtp_log);
+ smtp_pid = fake_smtp_start(&smtp_port, smtp_log, 1);
+ CHECK(smtp_pid > 0);
+ d = call(reqf("{\"v\":1,\"id\":\"pm13\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"%d\"}}",
+ g_session, (int)t->org_id, smtp_port));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"pm14\",\"cmd\":\"payroll.payslip_mail\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld,"
+ "\"employee_id\":%lld}}",
+ g_session, (int)t->org_id, (long long)reject_run,
+ (long long)anna_id));
+ CHECK_STR(d, "error.code", "SMTP_FAILED");
+ CHECK(jstr(d, "error.message") && strstr(jstr(d, "error.message"), "550"));
+ yyjson_doc_free(d);
+ if (smtp_pid > 0)
+ waitpid(smtp_pid, NULL, 0);
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM voucher_attachments va JOIN attachments a"
+ " ON a.org_id=va.org_id AND a.id=va.attachment_id"
+ " WHERE va.org_id=%d AND va.voucher_id=%lld"
+ " AND a.filename='Lönebesked 2026-05 Anna Anställd.pdf'",
+ (int)t->org_id, (long long)reject_voucher);
+ CHECK(db_count(g_db, sql) == 1);
+
+ /* a fresh org has no SMTP configuration */
+ d = call(reqf("{\"v\":1,\"id\":\"pm15\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":{\"name\":\"Lön utan SMTP AB\"}}",
+ g_session));
+ CHECK_OK(d);
+ int64_t nomail_org = jint(d, "result.id");
+ CHECK(nomail_org > 0);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm16\",\"cmd\":\"employee.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"Eva\","
+ "\"personal_no\":\"19850101-1234\",\"monthly_salary_ore\":"
+ "2500000,\"tax_table\":30,\"tax_column\":1,"
+ "\"email\":\"eva@example.se\"}}",
+ g_session, (int)nomail_org));
+ CHECK_OK(d);
+ int64_t eva = jint(d, "result.id");
+ CHECK(eva > 0);
+ CHECK_STR(d, "result.email", "eva@example.se");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm17\",\"cmd\":\"payroll.run_post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"period\":\"2026-08\",\"pay_date\":\"2026-08-25\"}}",
+ g_session, (int)nomail_org));
+ CHECK_OK(d);
+ int64_t nomail_run = jint(d, "result.id");
+ CHECK(nomail_run > 0);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"pm18\",\"cmd\":\"payroll.payslip_mail\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"run_id\":%lld}}",
+ g_session, (int)nomail_org, (long long)nomail_run));
+ CHECK_STR(d, "error.code", "SMTP_NOT_CONFIGURED");
+ yyjson_doc_free(d);
+ snprintf(sql, sizeof sql,
+ "SELECT count(*) FROM attachments WHERE org_id=%d",
+ (int)nomail_org);
+ CHECK(db_count(g_db, sql) == 0);
+
+ unsetenv("BOKFD_SECRET_KEY");
+}
+
struct ttest {
const char *name;
void (*fn)(struct tctx *);
@@ -4787,6 +5138,8 @@ static const struct ttest TESTS[] = {
{ "employees", test_employees, "org_members" },
{ "tax_tables", test_tax_tables, "org_members" },
{ "payroll", test_payroll, "employees tax_tables" },
+ { "payslip", test_payslip, "payroll" },
+ { "payslip_mail", test_payslip_mail, "payroll" },
{ "transport", test_transport, "" },
{ "pre_migration_snapshot", test_pre_migration_snapshot, "" },
{ "rate_limit", test_rate_limit, "" },