diff --git a/tests/skoda_client_control.test.js b/tests/skoda_client_control.test.js
new file mode 100644
index 00000000..f72ad6e3
--- /dev/null
+++ b/tests/skoda_client_control.test.js
@@ -0,0 +1,79 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { SkodaClient, SkodaApiError } = require('../src/services/skoda/skodaClient');
+const { API_BASE } = require('../src/services/skoda/skodaAuth');
+
+function okRes(status = 200) {
+ return { status, ok: status < 400, headers: new Headers(), json: async () => ({}), text: async () => '' };
+}
+function makeClient(routes, { session = { accessToken: 'AT', refreshToken: 'RT' } } = {}) {
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ calls.push({ url, method: opts.method, body: opts.body, auth: (opts.headers || {}).authorization, ct: (opts.headers || {})['content-type'] });
+ for (const [m, r] of routes) if (url.includes(m)) return typeof r === 'function' ? r(url, opts) : r;
+ throw new Error('unexpected ' + url);
+ };
+ return { client: new SkodaClient({ getSession: () => session, saveSession: () => {}, fetchImpl }), calls };
+}
+
+test('startAc POSTs the correct path and body with bearer + content-type', async () => {
+ const { client, calls } = makeClient([['/air-conditioning/V/start', okRes(202)]]);
+ await client.startAc('V', 21.4);
+ const c = calls[0];
+ assert.equal(c.method, 'POST');
+ assert.equal(c.url, API_BASE + '/api/v2/air-conditioning/V/start');
+ assert.equal(c.auth, 'Bearer AT');
+ assert.equal(c.ct, 'application/json');
+ const body = JSON.parse(c.body);
+ assert.equal(body.heaterSource, 'ELECTRIC');
+ assert.equal(body.targetTemperature.temperatureValue, 21); // rounded
+ assert.equal(body.targetTemperature.unitInCar, 'CELSIUS');
+});
+
+test('stopAc and window heating POST with no body', async () => {
+ const { client, calls } = makeClient([
+ ['/air-conditioning/V/stop', okRes()],
+ ['/start-window-heating', okRes()],
+ ]);
+ await client.stopAc('V');
+ await client.startWindowHeating('V');
+ assert.equal(calls[0].method, 'POST');
+ assert.equal(calls[0].body, undefined);
+ assert.match(calls[1].url, /\/api\/v2\/air-conditioning\/V\/start-window-heating$/);
+});
+
+test('setChargeLimit uses PUT with targetSOCInPercent', async () => {
+ const { client, calls } = makeClient([['/set-charge-limit', okRes()]]);
+ await client.setChargeLimit('V', 80);
+ assert.equal(calls[0].method, 'PUT');
+ assert.match(calls[0].url, /\/api\/v1\/charging\/V\/set-charge-limit$/);
+ assert.equal(JSON.parse(calls[0].body).targetSOCInPercent, 80);
+});
+
+test('lock/unlock POST currentSpin to vehicle-access', async () => {
+ const { client, calls } = makeClient([['/vehicle-access/V/unlock', okRes()]]);
+ await client.unlock('V', '1234');
+ assert.match(calls[0].url, /\/api\/v1\/vehicle-access\/V\/unlock$/);
+ assert.equal(JSON.parse(calls[0].body).currentSpin, '1234');
+});
+
+test('control 401 refreshes once then retries', async () => {
+ let n = 0;
+ const { client } = makeClient([
+ ['/charging/V/start', () => (n++ === 0 ? okRes(401) : okRes())],
+ ['/authentication/refresh-token', { status: 200, ok: true, headers: new Headers(), json: async () => ({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' }) }],
+ ]);
+ await client.startCharging('V'); // must not throw
+ assert.equal(n, 2);
+});
+
+test('control 429 maps to SKODA_RATE_LIMITED', async () => {
+ const { client } = makeClient([['/charging/V/stop', okRes(429)]]);
+ await assert.rejects(client.stopCharging('V'), (e) => e.code === 'SKODA_RATE_LIMITED');
+});
+
+test('control 4xx maps to SKODA_API_ERROR with status', async () => {
+ const { client } = makeClient([['/vehicle-access/V/lock', okRes(400)]]);
+ await assert.rejects(client.lock('V', '0000'), (e) => e.code === 'SKODA_API_ERROR' && e.status === 400);
+});
diff --git a/tests/skoda_command_api.test.js b/tests/skoda_command_api.test.js
new file mode 100644
index 00000000..cbe55caa
--- /dev/null
+++ b/tests/skoda_command_api.test.js
@@ -0,0 +1,62 @@
+'use strict';
+const { test, before, after, beforeEach, mock } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let ctx, accounts, control, getDb, vehId;
+
+before(async () => {
+ ctx = await setup();
+ accounts = require('../src/services/skoda/skodaAccounts');
+ control = require('../src/services/skoda/skodaControl');
+ ({ getDb } = require('../src/db/connection'));
+});
+after(async () => { await teardown(); });
+beforeEach(() => {
+ for (const a of accounts.listAccounts()) accounts.removeAccount(a.id);
+ const acc = accounts.createAccount({ email: 'a@x.y', password: 'pw' });
+ getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINA', 'Elroq', '{}', datetime('now'))").run(acc.id);
+ vehId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINA'").get().id;
+});
+
+test('POST command forwards to runCommand and returns ok', async () => {
+ const m = mock.method(control, 'runCommand', async () => ({ ok: true }));
+ const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vehId}/command`).set('x-csrf-token', ctx.csrfToken).send({ action: 'ac_start', args: { temp: 21 } });
+ assert.equal(res.status, 200);
+ assert.equal(m.mock.calls[0].arguments[1], 'ac_start');
+ m.mock.restore();
+});
+
+test('unknown command maps to 400', async () => {
+ const err = Object.assign(new Error('x'), { code: 'SKODA_UNKNOWN_COMMAND' });
+ const m = mock.method(control, 'runCommand', async () => { throw err; });
+ const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vehId}/command`).set('x-csrf-token', ctx.csrfToken).send({ action: 'x' });
+ assert.equal(res.status, 400);
+ assert.equal(res.body.code, 'SKODA_UNKNOWN_COMMAND');
+ m.mock.restore();
+});
+
+test('spin required maps to 409', async () => {
+ const err = Object.assign(new Error('x'), { code: 'SKODA_SPIN_REQUIRED' });
+ const m = mock.method(control, 'runCommand', async () => { throw err; });
+ const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vehId}/command`).set('x-csrf-token', ctx.csrfToken).send({ action: 'unlock' });
+ assert.equal(res.status, 409);
+ m.mock.restore();
+});
+
+test('PUT spin sets it (validated) and never echoes it', async () => {
+ const accId = accounts.listAccounts()[0].id;
+ const res = await ctx.agent.put(`/api/v1/skoda/accounts/${accId}/spin`).set('x-csrf-token', ctx.csrfToken).send({ spin: '1234' });
+ assert.equal(res.status, 200);
+ assert.ok(!JSON.stringify(res.body).includes('1234'));
+ assert.equal(accounts.listAccounts()[0].has_spin, true);
+ const bad = await ctx.agent.put(`/api/v1/skoda/accounts/${accId}/spin`).set('x-csrf-token', ctx.csrfToken).send({ spin: 'ab' });
+ assert.equal(bad.status, 400);
+});
+
+test('command requires admin session (unauth 401)', async () => {
+ const supertest = require('supertest');
+ const res = await supertest(ctx.app).post(`/api/v1/skoda/vehicles/${vehId}/command`).send({ action: 'ac_stop' });
+ assert.equal(res.status, 401);
+});
diff --git a/tests/skoda_control.test.js b/tests/skoda_control.test.js
new file mode 100644
index 00000000..57ec5647
--- /dev/null
+++ b/tests/skoda_control.test.js
@@ -0,0 +1,91 @@
+'use strict';
+const { test, before, after, beforeEach, mock } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let control, accounts, skoda, getDb, vehId, accId;
+
+function apiFetch(spy) {
+ return async (url, opts = {}) => {
+ spy.push({ url, method: opts.method, body: opts.body });
+ return { status: 202, ok: true, headers: new Headers(), json: async () => ({}), text: async () => '' };
+ };
+}
+
+before(async () => {
+ await setup();
+ control = require('../src/services/skoda/skodaControl');
+ accounts = require('../src/services/skoda/skodaAccounts');
+ skoda = require('../src/services/skoda');
+ ({ getDb } = require('../src/db/connection'));
+});
+after(async () => { skoda.stopPolling(); await teardown(); });
+beforeEach(() => {
+ control._resetForTest();
+ for (const a of accounts.listAccounts()) accounts.removeAccount(a.id);
+ const acc = accounts.createAccount({ email: 'c@x.y', password: 'pw' });
+ accounts.saveSession(acc.id, { accessToken: 'AT', refreshToken: 'RT' });
+ accId = acc.id;
+ getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINCTL', 'Elroq', '{}', datetime('now'))").run(acc.id);
+ vehId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINCTL'").get().id;
+ mock.method(skoda, 'refreshVehicle', async () => ({ ok: true })); // don't hit cloud on the post-command refresh
+});
+
+test('unknown action is rejected', async () => {
+ await assert.rejects(control.runCommand(vehId, 'explode', {}), (e) => e.code === 'SKODA_UNKNOWN_COMMAND');
+});
+
+test('prototype keys are not treated as commands', async () => {
+ await assert.rejects(control.runCommand(vehId, 'constructor', {}), (e) => e.code === 'SKODA_UNKNOWN_COMMAND');
+});
+
+test('command on an account without a session is rejected with SKODA_NO_SESSION', async () => {
+ accounts.saveSession(accId, null); // drop the session
+ const spy = [];
+ await assert.rejects(control.runCommand(vehId, 'ac_stop', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_NO_SESSION');
+ assert.equal(spy.length, 0);
+});
+
+test('ac_start without a temperature is rejected (NaN guard)', async () => {
+ const spy = [];
+ await assert.rejects(control.runCommand(vehId, 'ac_start', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION');
+ assert.equal(spy.length, 0);
+});
+
+test('ac_start validates temperature range', async () => {
+ const spy = [];
+ await assert.rejects(control.runCommand(vehId, 'ac_start', { temp: 40 }, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION');
+ assert.equal(spy.length, 0); // never reached the cloud
+});
+
+test('charge_limit only accepts allowed steps', async () => {
+ const spy = [];
+ await assert.rejects(control.runCommand(vehId, 'charge_limit', { limit: 55 }, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION');
+ const spy2 = [];
+ await control.runCommand(vehId, 'charge_limit', { limit: 80 }, { fetchImpl: apiFetch(spy2) });
+ assert.equal(JSON.parse(spy2[0].body).targetSOCInPercent, 80);
+});
+
+test('ac_start reaches the cloud with rounded temp', async () => {
+ const spy = [];
+ const r = await control.runCommand(vehId, 'ac_start', { temp: 21.6 }, { fetchImpl: apiFetch(spy) });
+ assert.equal(r.ok, true);
+ assert.match(spy[0].url, /\/air-conditioning\/VINCTL\/start$/);
+ assert.equal(JSON.parse(spy[0].body).targetTemperature.temperatureValue, 22);
+});
+
+test('lock without a set S-PIN is rejected before any cloud call', async () => {
+ const spy = [];
+ await assert.rejects(control.runCommand(vehId, 'lock', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_SPIN_REQUIRED');
+ assert.equal(spy.length, 0);
+});
+
+test('unlock uses the stored S-PIN and rate-limits after 5 attempts', async () => {
+ accounts.setSpin(accId, '4321');
+ const spy = [];
+ for (let i = 0; i < 5; i++) await control.runCommand(vehId, 'unlock', {}, { fetchImpl: apiFetch(spy) });
+ assert.equal(JSON.parse(spy[0].body).currentSpin, '4321');
+ await assert.rejects(control.runCommand(vehId, 'unlock', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_COMMAND_RATE_LIMIT');
+ assert.equal(spy.length, 5); // 6th blocked before cloud
+});
diff --git a/tests/skoda_control_i18n.test.js b/tests/skoda_control_i18n.test.js
new file mode 100644
index 00000000..107cd92b
--- /dev/null
+++ b/tests/skoda_control_i18n.test.js
@@ -0,0 +1,26 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const de = require('../src/i18n/de.json');
+const en = require('../src/i18n/en.json');
+
+const KEYS = ['skoda.cmd.ac_on','skoda.cmd.ac_off','skoda.cmd.charge_on','skoda.cmd.charge_off','skoda.cmd.window_heat_on','skoda.cmd.window_heat_off','skoda.cmd.lock','skoda.cmd.unlock','skoda.cmd.set_temp','skoda.cmd.set_limit','skoda.cmd.confirm_unlock','skoda.cmd.running','skoda.cmd.failed','skoda.cmd.spin','skoda.cmd.spin_set'];
+
+test('skoda.cmd.* keys exist in de and en', () => {
+ for (const k of KEYS) { assert.ok(de[k] && de[k].trim(), `de ${k}`); assert.ok(en[k] && en[k].trim(), `en ${k}`); }
+});
+test('all three layouts carry the skoda.cmd.* GC.t whitelist', () => {
+ for (const theme of ['aurora','default','pro']) {
+ const layout = fs.readFileSync(path.join(__dirname,'..','templates',theme,'layout.njk'),'utf8');
+ for (const k of KEYS) assert.ok(layout.includes(`'${k}'`), `${theme} ${k}`);
+ }
+});
+
+test('skoda.js wires command buttons to the admin command endpoint', () => {
+ const js = fs.readFileSync(path.join(__dirname,'..','public','js','skoda.js'),'utf8');
+ assert.match(js, /function command/);
+ assert.match(js, /\/vehicles\/.*\/command/);
+ assert.match(js, /data-cmd/);
+});
diff --git a/tests/skoda_portal_control.test.js b/tests/skoda_portal_control.test.js
new file mode 100644
index 00000000..d786dea5
--- /dev/null
+++ b/tests/skoda_portal_control.test.js
@@ -0,0 +1,59 @@
+'use strict';
+const { test, beforeEach, afterEach, mock } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const supertest = require('supertest');
+const { setup, teardown, getAgent } = require('./helpers/setup');
+const config = require('../config/default');
+const license = require('../src/services/license');
+const control = require('../src/services/skoda/skodaControl');
+const HOME_HOST = `home.${config.dns.domain}`;
+let app, accounts, owners, getDb, adminId, foreignId, mineId, foreignVehId;
+
+beforeEach(async () => {
+ await setup();
+ app = require('../src/app').createApp();
+ accounts = require('../src/services/skoda/skodaAccounts');
+ owners = require('../src/services/skoda/skodaOwners');
+ ({ getDb } = require('../src/db/connection'));
+ license.hasFeature = () => true;
+ adminId = getDb().prepare("SELECT id FROM users WHERE role='admin'").get().id;
+ foreignId = getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES ('frau','x','user')").run().lastInsertRowid;
+ const acc = accounts.createAccount({ email: 'a@x.y', password: 'pw' });
+ getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINM', 'Elroq', '{}', datetime('now'))").run(acc.id);
+ mineId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINM'").get().id;
+ owners.setOwners(mineId, [adminId]);
+ getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINF', 'Enyaq', '{}', datetime('now'))").run(acc.id);
+ foreignVehId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINF'").get().id;
+ owners.setOwners(foreignVehId, [foreignId]);
+});
+afterEach(async () => { await teardown(); });
+
+test('logged-in owner can command own vehicle', async () => {
+ const m = mock.method(control, 'runCommand', async () => ({ ok: true }));
+ const agent = await getAgent();
+ const res = await agent.post(`/api/v1/portal/skoda/vehicles/${mineId}/command`).set('Host', HOME_HOST).send({ action: 'ac_start', args: { temp: 21 } });
+ assert.equal(res.status, 200);
+ assert.equal(m.mock.callCount(), 1);
+ m.mock.restore();
+});
+
+test('unauthenticated command returns 200 + reason login_required (no cloud call, Midea-parity)', async () => {
+ const m = mock.method(control, 'runCommand', async () => ({ ok: true }));
+ const res = await supertest(app).post(`/api/v1/portal/skoda/vehicles/${mineId}/command`).set('Host', HOME_HOST).send({ action: 'ac_stop' });
+ assert.equal(res.status, 200);
+ assert.equal(res.body.reason, 'login_required');
+ assert.equal(m.mock.callCount(), 0);
+ m.mock.restore();
+});
+
+test('commanding a foreign vehicle is 403 SKODA_NOT_OWNER', async () => {
+ const m = mock.method(control, 'runCommand', async () => ({ ok: true }));
+ const agent = await getAgent();
+ const res = await agent.post(`/api/v1/portal/skoda/vehicles/${foreignVehId}/command`).set('Host', HOME_HOST).send({ action: 'ac_stop' });
+ assert.equal(res.status, 403);
+ assert.equal(res.body.error, 'SKODA_NOT_OWNER');
+ assert.equal(m.mock.callCount(), 0);
+ m.mock.restore();
+});
diff --git a/tests/skoda_portal_control_ui.test.js b/tests/skoda_portal_control_ui.test.js
new file mode 100644
index 00000000..fba53ed3
--- /dev/null
+++ b/tests/skoda_portal_control_ui.test.js
@@ -0,0 +1,21 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const de = require('../src/i18n/de.json');
+const en = require('../src/i18n/en.json');
+const KEYS = ['portal.skoda.cmd_ac_on','portal.skoda.cmd_ac_off','portal.skoda.cmd_set_temp','portal.skoda.cmd_charge_on','portal.skoda.cmd_charge_off','portal.skoda.cmd_charge_limit','portal.skoda.cmd_window_heat','portal.skoda.cmd_window_heat_off','portal.skoda.cmd_lock','portal.skoda.cmd_unlock','portal.skoda.cmd_confirm_unlock','portal.skoda.cmd_running','portal.skoda.cmd_failed'];
+const PT = ['skodaCmdAcOn','skodaCmdAcOff','skodaCmdSetTemp','skodaCmdChargeOn','skodaCmdChargeOff','skodaCmdChargeLimit','skodaCmdWindowHeat','skodaCmdWindowHeatOff','skodaCmdLock','skodaCmdUnlock','skodaCmdConfirmUnlock','skodaCmdRunning','skodaCmdFailed'];
+
+test('portal.skoda.cmd_* keys in de and en', () => {
+ for (const k of KEYS) { assert.ok(de[k] && de[k].trim(), `de ${k}`); assert.ok(en[k] && en[k].trim(), `en ${k}`); }
+});
+test('portal.njk PT block + portal.js command wiring, gated on loggedIn', () => {
+ const njk = fs.readFileSync(path.join(__dirname,'..','templates','portal','portal.njk'),'utf8');
+ for (const k of PT) assert.ok(njk.includes(k), `njk ${k}`);
+ const js = fs.readFileSync(path.join(__dirname,'..','public','js','portal.js'),'utf8');
+ assert.match(js, /skodaCommand/);
+ assert.match(js, /\/api\/v1\/portal\/skoda\/vehicles\//);
+ assert.match(js, /loggedIn/); // buttons only when logged in
+});
diff --git a/tests/skoda_schema.test.js b/tests/skoda_schema.test.js
index b01d0a75..02c20097 100644
--- a/tests/skoda_schema.test.js
+++ b/tests/skoda_schema.test.js
@@ -14,7 +14,7 @@ test('skoda tables exist with expected columns', () => {
const cols = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
assert.deepEqual(
cols('skoda_accounts').sort(),
- ['backoff_min', 'created_at', 'email', 'id', 'next_retry_at', 'password_enc', 'session_enc', 'status', 'status_detail', 'updated_at']
+ ['backoff_min', 'created_at', 'email', 'id', 'next_retry_at', 'password_enc', 'session_enc', 'spin_enc', 'status', 'status_detail', 'updated_at']
);
assert.deepEqual(
cols('skoda_vehicles').sort(),
diff --git a/tests/skoda_spin.test.js b/tests/skoda_spin.test.js
new file mode 100644
index 00000000..c1f917cd
--- /dev/null
+++ b/tests/skoda_spin.test.js
@@ -0,0 +1,41 @@
+'use strict';
+const { test, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let accounts; let getDb;
+
+before(async () => { await setup(); accounts = require('../src/services/skoda/skodaAccounts'); ({ getDb } = require('../src/db/connection')); });
+after(async () => { await teardown(); });
+beforeEach(() => { for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); });
+
+test('spin_enc column exists on skoda_accounts', () => {
+ const cols = getDb().prepare('PRAGMA table_info(skoda_accounts)').all().map((c) => c.name);
+ assert.ok(cols.includes('spin_enc'));
+});
+
+test('setSpin encrypts at rest, getSpin roundtrips, has_spin reflects it', () => {
+ const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' });
+ assert.equal(accounts.listAccounts()[0].has_spin, false);
+ accounts.setSpin(acc.id, '1234');
+ const row = getDb().prepare('SELECT spin_enc FROM skoda_accounts WHERE id = ?').get(acc.id);
+ assert.notEqual(row.spin_enc, '1234');
+ assert.match(row.spin_enc, /^[0-9a-f]{24}:[0-9a-f]{32}:/);
+ assert.equal(accounts.getSpin(acc.id), '1234');
+ const listed = accounts.listAccounts()[0];
+ assert.equal(listed.has_spin, true);
+ assert.equal('spin_enc' in listed, false);
+ assert.equal('spin' in listed, false);
+});
+
+test('setSpin rejects non-numeric or wrong-length', () => {
+ const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' });
+ assert.throws(() => accounts.setSpin(acc.id, 'abcd'), (e) => e.code === 'SKODA_VALIDATION');
+ assert.throws(() => accounts.setSpin(acc.id, '12'), (e) => e.code === 'SKODA_VALIDATION');
+});
+
+test('getSpin returns null when unset', () => {
+ const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' });
+ assert.equal(accounts.getSpin(acc.id), null);
+});