fix tests and change invitation mechanics
This commit is contained in:
+62
-23
@@ -1,5 +1,4 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import nodeCrypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -58,7 +57,7 @@ async function fixture(t) {
|
||||
test('shared draft clones a published route and protects its contents and ownership', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {}, outsider = {};
|
||||
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
|
||||
const { value: created, response } = await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
|
||||
assert.equal(created.code.length, 5);
|
||||
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value)));
|
||||
assert.match(response.headers.get('set-cookie'), /HttpOnly/);
|
||||
@@ -98,14 +97,14 @@ test('shared draft clones a published route and protects its contents and owners
|
||||
await request(guest, `/routes/${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
|
||||
await request(guest, `/routes/0${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
|
||||
await request(guest, `/routes/${route.id}/restore`, 'POST', {}, 403, { 'If-Match': '1' });
|
||||
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
|
||||
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
await request(outsider, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
|
||||
await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) });
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE');
|
||||
await request(guest, `/routes/${route.id}`, 'GET', null, 403);
|
||||
assert.equal((await request(guest, '/groups')).value.groups.length, 0);
|
||||
await request(host, `/routes/${route.id}`);
|
||||
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
|
||||
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
assert.equal(reopened.group.active, true);
|
||||
await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) });
|
||||
assert.equal((await request(guest, '/groups')).value.groups.length, 1);
|
||||
@@ -115,10 +114,10 @@ test('invitation expiry, request origin, invalid IDs and expired sessions are en
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
await request(host, '/groups', 'POST', { routeId: source.id }, 400);
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
|
||||
await request(host, '/groups', 'POST', { routeId: 900, creator_id: 'a'.repeat(64) }, 404);
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
|
||||
await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
|
||||
await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: 900, creator_id: 'a'.repeat(64) }, 404);
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(host, `/groups/${'a'.repeat(32)}/code`, 'POST', {}, 404);
|
||||
db.prepare('UPDATE collaboration_groups SET code_expires_at = 0 WHERE id = ?').run(created.group.id);
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
|
||||
@@ -136,7 +135,7 @@ test('invitation expiry, request origin, invalid IDs and expired sessions are en
|
||||
test('overlapping writes are locked until completion and revision changes once', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const guard = groupRouteGuard(db);
|
||||
const req = { method: 'PUT', path: `/routes/${created.group.route.id}`, headers: { cookie: host.cookie, 'if-match': '0' } };
|
||||
const res = new EventEmitter(); res.setHeader = () => {}; res.statusCode = 200;
|
||||
@@ -153,7 +152,7 @@ test('group copying rolls back a failed media copy and rate limits invalid invit
|
||||
const { db, source, request } = await fixture(t);
|
||||
const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get();
|
||||
fs.rmSync(resolveStoredFile(image.path));
|
||||
await request({}, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
|
||||
await request({}, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0);
|
||||
const client = {};
|
||||
@@ -166,7 +165,7 @@ test('group copying rolls back a failed media copy and rate limits invalid invit
|
||||
test('point lock files bind the device and session, require confirmed takeover and reject stale content', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {}, outsider = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const route = created.group.route, poi = route.pois[0];
|
||||
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
|
||||
@@ -215,14 +214,14 @@ test('point lock files bind the device and session, require confirmed takeover a
|
||||
test('different station edits do not conflict through the route revision, and in-flight writes prevent takeover', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const route = created.group.route;
|
||||
const second = createPoi(db, route.id, { title: 'Second stop' });
|
||||
const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`;
|
||||
const first = route.pois[0];
|
||||
const a = (await request(host, lockPath(first.id), 'POST', {})).value;
|
||||
const b = (await request(guest, lockPath(second.id), 'POST', {})).value;
|
||||
const a = (await request(host, lockPath(first.id), 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
const b = (await request(guest, lockPath(second.id), 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
await request(host, `/routes/${route.id}/pois/${first.id}`, 'PUT', { title: 'A' }, 200,
|
||||
{ 'X-Poi-Lock': a.token, 'If-Poi-Match': a.version, 'If-Match': '0' });
|
||||
await request(guest, `/routes/${route.id}/pois/${second.id}`, 'PUT', { title: 'B' }, 200,
|
||||
@@ -243,9 +242,8 @@ test('different station edits do not conflict through the route revision, and in
|
||||
|
||||
test('five-symbol invitations allow repeated motifs and reject other code lengths', async t => {
|
||||
const { source, request } = await fixture(t);
|
||||
t.mock.method(nodeCrypto, 'randomInt', () => 5);
|
||||
const host = {}, guest = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
assert.deepEqual(created.code, Array(5).fill('🐦'));
|
||||
await request(guest, '/groups/join', 'POST', { code: Array(4).fill('🐦'), creator_id: 'b'.repeat(64) }, 404);
|
||||
await request(guest, '/groups/join', 'POST', { code: Array(6).fill('🐦'), creator_id: 'b'.repeat(64) }, 404);
|
||||
@@ -254,18 +252,18 @@ test('five-symbol invitations allow repeated motifs and reject other code length
|
||||
});
|
||||
|
||||
|
||||
test('invitations expire after thirty minutes, wait for active locks and rotate after sixty idle minutes', async t => {
|
||||
test('invitations extend on editing, expire after sixty idle minutes and never rotate', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
const started = Date.now();
|
||||
let clock = started;
|
||||
t.mock.method(Date, 'now', () => clock);
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
assert.equal(created.expiresAt, Math.floor(started / 1000) + 1800);
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
assert.equal(created.expiresAt, Math.floor(started / 1000) + 3600);
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const group = created.group, route = group.route, poi = route.pois[0];
|
||||
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
|
||||
const lock = (await request(guest, endpoint, 'POST', {})).value;
|
||||
const lock = (await request(guest, endpoint, 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
const file = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`);
|
||||
const firstActivity = JSON.parse(fs.readFileSync(file)).updated_at;
|
||||
clock += 31 * 60_000;
|
||||
@@ -273,7 +271,8 @@ test('invitations expire after thirty minutes, wait for active locks and rotate
|
||||
const held = (await request(host, `/groups/${group.id}/invitation`)).value;
|
||||
assert.equal(held.busy, true);
|
||||
assert.equal(held.expiresAt, created.expiresAt);
|
||||
await request({}, '/groups/join', 'POST', { code: created.code, creator_id: 'c'.repeat(64) }, 404);
|
||||
assert.deepEqual(held.code, created.code);
|
||||
assert.equal(held.expired, false);
|
||||
await request(guest, `/groups/${group.id}/invitation`, 'GET', null, 403);
|
||||
await request(host, `/groups/${group.id}/code`, 'POST', {}, 423);
|
||||
await request(host, endpoint, 'GET', null, 423);
|
||||
@@ -292,7 +291,47 @@ test('invitations expire after thirty minutes, wait for active locks and rotate
|
||||
const renewed = (await request(host, `/groups/${group.id}/invitation`)).value;
|
||||
assert.equal(renewed.busy, false);
|
||||
assert.equal(renewed.code.length, 5);
|
||||
assert.equal(renewed.expiresAt, Math.floor(clock / 1000) + 1800);
|
||||
await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) });
|
||||
assert.equal(renewed.expiresAt, Math.floor(started / 1000) + 91 * 60);
|
||||
assert.equal(renewed.expired, true);
|
||||
assert.deepEqual(renewed.code, created.code);
|
||||
await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) }, 404);
|
||||
await request(host, endpoint.replace(String(poi.id) + '/lock', '99999/lock'), 'GET', null, 404);
|
||||
});
|
||||
|
||||
test('chosen codes persist across connections, collisions fail without copies and passive queries never extend expiry', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
const code = ['🐦', '🪨', '🐦', '🎃', '🍄'];
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 400);
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64), code }, 201)).value;
|
||||
assert.deepEqual(created.code, code);
|
||||
const second = openDatabase(db.location());
|
||||
try { assert.deepEqual(JSON.parse(second.prepare('SELECT code FROM collaboration_invitations WHERE group_id = ?').get(created.group.id).code), code); }
|
||||
finally { second.close(); }
|
||||
const total = db.prepare('SELECT COUNT(*) AS total FROM routes').get().total;
|
||||
const collision = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64), code }, 409);
|
||||
assert.equal(collision.value.details.code, 'code-taken');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, total);
|
||||
await request(host, `/groups/${created.group.id}/code`, 'POST', {}, 400);
|
||||
let clock = Date.now();
|
||||
t.mock.method(Date, 'now', () => clock);
|
||||
clock += 5 * 60_000;
|
||||
const status = (await request(host, `/groups/${created.group.id}/invitation`)).value;
|
||||
assert.deepEqual(status.code, code);
|
||||
assert.equal(status.expiresAt, created.expiresAt);
|
||||
await request(guest, '/groups/join', 'POST', { code, creator_id: 'b'.repeat(64) });
|
||||
const expiry = () => db.prepare('SELECT code_expires_at FROM collaboration_groups WHERE id = ?').get(created.group.id).code_expires_at;
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3600);
|
||||
clock += 5 * 60_000;
|
||||
await request(host, '/groups');
|
||||
await request(guest, `/routes/${created.group.route.id}`);
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3300);
|
||||
await request(guest, `/groups/${created.group.id}/edit`, 'POST', {});
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3600);
|
||||
await request({}, `/groups/${created.group.id}/edit`, 'POST', {}, 401);
|
||||
clock += 5 * 60_000;
|
||||
await request(guest, `/routes/${created.group.route.id}`, 'PUT', { name: 'Changed again' }, 200, { 'If-Match': '0' });
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3600);
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_invitations').get().total, 0);
|
||||
});
|
||||
|
||||
@@ -1013,10 +1013,13 @@ test('shared drafts use guarded server saves, preserve the creator and retain un
|
||||
test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
|
||||
const h = buildEditorContext();
|
||||
const sharedIds = [];
|
||||
const code = Array(5).fill('🐦');
|
||||
let chosen = null;
|
||||
h.context.confirm = () => false;
|
||||
h.context.Wegwichtel.Groups = {
|
||||
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
|
||||
async share(id) { sharedIds.push(id); }
|
||||
async chooseCode() { return chosen; },
|
||||
async share(id, selected) { assert.equal(selected, code); sharedIds.push(id); }
|
||||
};
|
||||
await loadI18n(h.context);
|
||||
const filename = path.join(root, 'public/js/editor-ui.js');
|
||||
@@ -1027,6 +1030,10 @@ test('sharing a local draft asks before sending and opens a separate shared copy
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
for (let i = 0; i < 4; i += 1) await tick();
|
||||
assert.equal(h.calls.some(call => call.method === 'POST'), false);
|
||||
chosen = code;
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
for (let i = 0; i < 4; i += 1) await tick();
|
||||
assert.equal(h.calls.some(call => call.method === 'POST'), false, 'declining transfer sends nothing');
|
||||
h.context.confirm = () => true;
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
|
||||
+72
-17
@@ -27,17 +27,18 @@ function key(node, value) {
|
||||
}
|
||||
async function harness(loading = false) {
|
||||
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
|
||||
'group-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route',
|
||||
'group-entry-title', 'group-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route',
|
||||
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
|
||||
const nodes = new Map(ids.map(id => [id, new Element()]));
|
||||
const document = new EventTarget();
|
||||
document.readyState = loading ? 'loading' : 'complete';
|
||||
document.getElementById = id => nodes.get(id);
|
||||
document.createElement = () => new Element();
|
||||
const calls = [], edits = [];
|
||||
const calls = [], edits = [], timers = [];
|
||||
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' };
|
||||
const group = () => ({ id: 'group-id', route: { id: 22, name: 'Waldweg' }, revision: 0, role: controls.role, active: controls.active });
|
||||
const context = { Event, document, Date, Map, Set, Headers, console, globalThis: null,
|
||||
const context = { Event, document, Date, Map, Set, Headers, console, crypto, globalThis: null,
|
||||
setInterval: (callback, delay) => { timers.push({ callback, delay }); },
|
||||
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
|
||||
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
|
||||
EditorUi: { async openGroup(route) { edits.push(route.id); } } },
|
||||
@@ -48,7 +49,7 @@ async function harness(loading = false) {
|
||||
let value;
|
||||
if (options.method === 'GET') value = { groups: [group()] };
|
||||
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
|
||||
else if (url.endsWith('/join')) value = { group: group() };
|
||||
else if (url.endsWith('/join') || url.endsWith('/edit')) value = { group: group() };
|
||||
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
|
||||
return { ok: true, json: async () => value };
|
||||
}
|
||||
@@ -59,7 +60,7 @@ async function harness(loading = false) {
|
||||
const filename = path.resolve('public/js', module + '.js');
|
||||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
|
||||
}
|
||||
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups };
|
||||
return { nodes, context, calls, controls, edits, timers, groups: context.Wegwichtel.Groups };
|
||||
}
|
||||
|
||||
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
|
||||
@@ -99,7 +100,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
||||
|
||||
test('sharing displays five motifs and the owner can renew or close access with confirmation', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, false);
|
||||
@@ -114,11 +115,10 @@ test('sharing displays five motifs and the owner can renew or close access with
|
||||
pointer(h.nodes.get('group-edit')); await tick();
|
||||
assert.deepEqual(h.edits, [22]);
|
||||
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||
h.controls.confirm = false;
|
||||
pointer(actions[2]); await tick();
|
||||
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
|
||||
h.controls.confirm = true;
|
||||
pointer(actions[2]); await tick();
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, true);
|
||||
pointer(h.nodes.get('group-join')); await tick();
|
||||
assert.ok(h.calls.some(call => call.url.endsWith('/code')));
|
||||
actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||
pointer(actions[1]); await tick();
|
||||
@@ -192,9 +192,9 @@ test('unsent station changes are listed before network access and discarded only
|
||||
assert.equal(list.children.length, 0);
|
||||
});
|
||||
|
||||
test('sharing opens the actual five-symbol invitation directly without a two-symbol preview', async () => {
|
||||
test('sharing a confirmed code shows the five-symbol invitation', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
assert.equal(h.nodes.get('group-dialog').open, true);
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, false);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||
@@ -209,14 +209,14 @@ test('sharing opens the actual five-symbol invitation directly without a two-sym
|
||||
test('an incomplete invitation is never displayed as a two-symbol group code', async () => {
|
||||
const h = await harness();
|
||||
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { route: { id: 7 } }, code: ['🐦', '🪨'] }) });
|
||||
await assert.rejects(h.groups.share(7));
|
||||
await assert.rejects(h.groups.share(7, Array(5).fill('🐦')));
|
||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 0);
|
||||
});
|
||||
|
||||
test('the open invitation refreshes its code automatically and shows a deferred renewal during station editing', async () => {
|
||||
test('the open invitation shows its current expiry without creating a code', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
const original = h.context.fetch;
|
||||
h.context.fetch = async (url, options) => {
|
||||
if (!url.endsWith('/invitation')) return original(url, options);
|
||||
@@ -224,14 +224,69 @@ test('the open invitation refreshes its code automatically and shows a deferred
|
||||
busy: true, expired: true, expiresAt: 1800000000 }) };
|
||||
};
|
||||
await h.groups.refreshInvitation();
|
||||
assert.match(h.nodes.get('group-status').textContent, /Station bearbeitet/);
|
||||
assert.match(h.nodes.get('group-status').textContent, /abgelaufen/);
|
||||
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
|
||||
busy: false, expired: false, expiresAt: 1800001800, code: Array(5).fill('🪨') }) });
|
||||
busy: false, expired: false, expiresAt: 1800001800, code: Array(5).fill('🐦') }) });
|
||||
await h.groups.refreshInvitation();
|
||||
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||
assert.match(h.nodes.get('group-code').children[0].src, /🪨/);
|
||||
assert.match(h.nodes.get('group-code').children[0].src, /🐦/);
|
||||
assert.equal(h.nodes.get('group-status').textContent, '');
|
||||
h.nodes.get('group-dialog').close();
|
||||
h.context.fetch = async () => { throw new Error('must not fetch a closed invitation'); };
|
||||
await h.groups.refreshInvitation();
|
||||
});
|
||||
|
||||
test('sharing first offers five random editable symbols, sends only the confirmed code and never opens the editor', async () => {
|
||||
const h = await harness();
|
||||
const sharing = h.groups.share(7);
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, true);
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.equal(h.nodes.get('group-entry-title').textContent, 'Code auswählen');
|
||||
const columns = h.nodes.get('group-wheels').children;
|
||||
assert.equal(columns.length, 5);
|
||||
for (const column of columns) {
|
||||
const wheel = column.children[1];
|
||||
assert.ok(Number(wheel.attributes.get('aria-valuenow')) >= 1);
|
||||
assert.equal(wheel.scrollTop, (Number(wheel.attributes.get('aria-valuenow')) - 1) * 96);
|
||||
key(wheel, 'Home');
|
||||
}
|
||||
pointer(columns[2].children[1].children[5]);
|
||||
pointer(h.nodes.get('group-join'));
|
||||
await sharing;
|
||||
assert.deepEqual(JSON.parse(h.calls[0].body).code, ['🪵', '🪵', '🐦', '🪵', '🪵']);
|
||||
assert.equal(h.nodes.get('group-dialog').open, true);
|
||||
assert.deepEqual(h.edits, []);
|
||||
});
|
||||
|
||||
test('cancelling code selection sends no request and a late double tap cannot join a group', async () => {
|
||||
const h = await harness();
|
||||
const sharing = h.groups.share(7);
|
||||
pointer(h.nodes.get('group-entry-close'));
|
||||
await sharing;
|
||||
pointer(h.nodes.get('group-join'));
|
||||
await tick();
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.deepEqual(h.edits, []);
|
||||
});
|
||||
|
||||
|
||||
test('the countdown decreases, reflects extended expiry and shows expiration without changing the code', async () => {
|
||||
const h = await harness();
|
||||
let clock = 1799996400000;
|
||||
h.context.Date = class extends Date { static now() { return clock; } };
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /60 Min. 00 Sek./);
|
||||
const timer = h.timers.find(timer => timer.delay === 1000);
|
||||
clock += 45_000;
|
||||
timer.callback();
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /59 Min. 15 Sek./);
|
||||
const code = h.nodes.get('group-code').children.map(image => image.src);
|
||||
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 } },
|
||||
code: Array(5).fill('🐦'), expiresAt: clock / 1000 + 3600 }) });
|
||||
await h.groups.refreshInvitation();
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /60 Min. 00 Sek./);
|
||||
clock += 3600_000;
|
||||
timer.callback();
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /abgelaufen/);
|
||||
assert.deepEqual(h.nodes.get('group-code').children.map(image => image.src), code);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,10 @@ const EXPECTED_SCHEMA = Object.freeze({
|
||||
revision: { type: 'INTEGER', notnull: 1 },
|
||||
created_at: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
collaboration_invitations: {
|
||||
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
code: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
collaboration_members: {
|
||||
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
session_hash: { type: 'TEXT', notnull: 1, pk: 2 },
|
||||
|
||||
Reference in New Issue
Block a user