Files
auto-team-custom/server/invite-join-mode.test.mjs

547 lines
28 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const serverScript = fileURLToPath(new URL('./index.mjs', import.meta.url));
const workspaceId = 'workspace-invite-mode-test';
const motherId = 'mother-invite-mode-test';
function token(accountId = '', marker = '') {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
exp: Math.floor(Date.now() / 1000) + 3600,
'https://api.openai.com/auth': accountId
? { chatgpt_account_id: accountId, chatgpt_plan_type: 'team' }
: { chatgpt_plan_type: 'free' },
})).toString('base64url');
return `${header}.${payload}.${marker || 'test-signature'}`;
}
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
return server.address().port;
}
async function unusedPort() {
const server = createServer();
const port = await listen(server);
await new Promise((resolve) => server.close(resolve));
return port;
}
async function waitForApp(baseUrl, app, output) {
let last = 'no_response';
for (let attempt = 0; attempt < 200; attempt += 1) {
if (app.exitCode !== null) throw new Error(`test app exited: ${output()}`);
try {
const response = await fetch(`${baseUrl}/api/auth/session`, { signal: AbortSignal.timeout(500) });
if (response.ok) return;
last = `HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`;
} catch (error) {
last = error?.message || String(error);
}
await delay(50);
}
throw new Error(`test app did not start (${last}): ${output()}`);
}
async function fixture(t, {
joinMode = 'invite', ownerEmail = 'owner@gmail.com', childEmails = ['child@different.test'],
seatType = 'default', defaultSeatType = seatType, paidDefault = 2, paidProlite = 0, heldProlite = 0,
invitationRows = [], confirmMember = true, confirmedSeatType = null, acceptStatus = 200,
acceptPayload = { success: true }, defaultSeatStatus = 200, defaultSeatPayload = { success: true },
initialMembers = [], pushConfigured = false,
} = {}) {
const requests = [];
const pushRequests = [];
const unexpected = [];
const ownerToken = token(workspaceId);
const members = [{ id: 'owner-user', email: ownerEmail, role: 'account-owner', seat_type: 'default' }, ...initialMembers];
const pendingInvites = [...invitationRows];
const pendingRequests = new Map();
const freeTokens = new Map(childEmails.map((email) => [email.toLowerCase(), token('', email)]));
let remoteDefaultSeatType = defaultSeatType;
const emailForAuthorization = (authorization) => [...freeTokens]
.find(([, accessToken]) => `Bearer ${accessToken}` === authorization)?.[0];
const assignedSeatType = () => {
if (confirmedSeatType) return confirmedSeatType;
const remaining = {
default: Math.max(0, paidDefault - members.filter((member) => member.seat_type === 'default').length),
prolite: Math.max(0, paidProlite - heldProlite - members.filter((member) => member.seat_type === 'prolite').length),
};
if (remaining[remoteDefaultSeatType] > 0) return remoteDefaultSeatType;
return ['default', 'prolite'].find((type) => remaining[type] > 0) || remoteDefaultSeatType;
};
let sub2api;
let sub2apiPort;
if (pushConfigured) {
sub2api = createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
let bodyText = '';
for await (const chunk of req) bodyText += chunk;
const body = bodyText ? JSON.parse(bodyText) : null;
pushRequests.push({ method: req.method, path: url.pathname, body });
let status = 200;
let data = {};
if (req.method === 'GET' && url.pathname === '/api/v1/admin/accounts') data = { items: [] };
else if (req.method === 'POST' && url.pathname === '/api/v1/admin/accounts') data = { id: 'sub2api-created' };
else status = 503;
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data }));
});
sub2apiPort = await listen(sub2api);
}
const upstream = createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
const bodyText = await new Promise((resolve) => {
let value = '';
req.on('data', (chunk) => { value += chunk; });
req.on('end', () => resolve(value));
});
let body = null;
try { body = bodyText ? JSON.parse(bodyText) : null; } catch { body = bodyText; }
const entry = { method: req.method, path: url.pathname, search: url.search, authorization: req.headers.authorization, body, bodyText };
requests.push(entry);
let payload = null;
let status = 200;
const base = `/backend-api/accounts/${workspaceId}`;
if (req.method === 'GET' && url.pathname === '/backend-api/subscriptions'
&& url.searchParams.get('account_id') === workspaceId) {
payload = {
seats_in_use: members.length,
seats_entitled: paidDefault + paidProlite,
seat_capacity: [
{ type: 'default', paid: paidDefault, held: 0 },
{ type: 'prolite', paid: paidProlite, held: heldProlite },
],
assigned: {
default: members.filter((member) => member.seat_type === 'default').length,
prolite: members.filter((member) => member.seat_type === 'prolite').length,
},
};
} else if (req.method === 'GET' && url.pathname === '/backend-api/wham/usage') {
payload = { rate_limit: { limit_reached: false, primary_window: { used_percent: 0 }, secondary_window: { used_percent: 0 } } };
} else if (req.method === 'GET' && url.pathname === `${base}/users`) {
const query = (url.searchParams.get('query') || '').toLowerCase();
const matching = query ? members.filter((member) => member.email.toLowerCase().includes(query)) : members;
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Number(url.searchParams.get('limit')) || 100;
payload = { items: matching.slice(offset, offset + limit), total: matching.length, offset, limit };
} else if (req.method === 'GET' && url.pathname === `${base}/invites`) {
payload = { items: pendingInvites, total: pendingInvites.length };
} else if (req.method === 'POST' && url.pathname === `${base}/invites`) {
const email = body?.email_addresses?.[0];
const invite = { email_address: email, id: `invitation-${pendingInvites.length + 1}` };
pendingInvites.push(invite);
payload = { account_invites: [invite] };
} else if (req.method === 'POST' && url.pathname === `${base}/invites/accept`) {
status = acceptStatus;
payload = acceptStatus === 200 ? acceptPayload : { error: 'mock_accept_failed' };
if (acceptStatus === 200 && acceptPayload.success !== false && confirmMember) {
const email = emailForAuthorization(req.headers.authorization);
if (email && !members.some((member) => member.email.toLowerCase() === email)) {
members.push({ id: `member-${email}`, email, role: 'standard-user', seat_type: assignedSeatType() });
}
}
} else if (req.method === 'POST' && url.pathname === `${base}/invites/request`) {
const email = emailForAuthorization(req.headers.authorization);
const requestId = `request-${pendingRequests.size + 1}`;
pendingRequests.set(requestId, email);
payload = { id: requestId };
} else if (req.method === 'POST' && url.pathname === `${base}/settings/default_seat_type`) {
status = defaultSeatStatus;
payload = defaultSeatStatus >= 200 && defaultSeatStatus < 300
? defaultSeatPayload
: { error: 'mock_default_seat_failed' };
if (status >= 200 && status < 300 && ['default', 'prolite'].includes(body?.value)) {
remoteDefaultSeatType = body.value;
}
} else if (req.method === 'PATCH' && url.pathname.startsWith(`${base}/invites/`)) {
if (body?.accept_request === true && confirmMember) {
const inviteId = decodeURIComponent(url.pathname.slice(`${base}/invites/`.length));
const email = pendingRequests.get(inviteId);
if (email && !members.some((member) => member.email.toLowerCase() === email)) {
members.push({ id: `member-${email}`, email, role: body.role || 'standard-user', seat_type: assignedSeatType() });
}
}
payload = { success: true };
} else if (req.method === 'GET' && url.pathname === '/api/auth/session') {
payload = { accessToken: token(workspaceId) };
} else {
unexpected.push(`${req.method} ${url.pathname}${url.search}`);
status = 503;
payload = { message: 'unexpected_remote_request' };
}
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(payload));
});
const upstreamPort = await listen(upstream);
const testRoot = path.resolve(tmpdir());
const dataDir = await mkdtemp(path.join(testRoot, 'invite-join-mode-'));
let app;
t.after(async () => {
if (app && app.exitCode === null && app.signalCode === null) {
const exited = new Promise((resolve) => app.once('exit', resolve));
app.kill();
await exited;
}
upstream.closeAllConnections?.();
await new Promise((resolve) => upstream.close(resolve));
if (sub2api) {
sub2api.closeAllConnections?.();
await new Promise((resolve) => sub2api.close(resolve));
}
assert.equal(path.dirname(path.resolve(dataDir)), testRoot);
assert.match(path.basename(dataDir), /^invite-join-mode-/);
await rm(dataDir, { recursive: true, force: true });
});
await writeFile(path.join(dataDir, 'state.json'), JSON.stringify({
version: 1,
settings: {
autoRefill: false, promoteJoinedAccounts: false, concurrency: 3, joinMode,
...(pushConfigured ? { integrations: { sub2apis: [{ id: 'test-sub2api', name: 'Mock Sub2API', enabled: true, baseUrl: `http://127.0.0.1:${sub2apiPort}`, apiKey: 'test-key', groupId: 11 }] } } : {}),
},
mothers: [{
id: motherId, team: workspaceId, accountId: workspaceId, teamName: 'Invitation Fixture',
email: ownerEmail, primaryOwnerEmail: ownerEmail, accessToken: ownerToken,
...(pushConfigured ? { sub2apiIntegrationId: 'test-sub2api' } : {}),
defaultSeatType, members: [],
}],
children: childEmails.map((email, index) => ({
id: `child-${index}`, email, status: 'ready', accessToken: freeTokens.get(email.toLowerCase()), workspaceHistory: [],
})),
history: [],
}));
const baseUrl = `http://127.0.0.1:${await unusedPort()}`;
let output = '';
app = spawn(process.execPath, [serverScript], {
env: {
...process.env,
HOST: '127.0.0.1', PORT: new URL(baseUrl).port,
CHATGPT_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
TEAM_ROTATION_DATA_DIR: dataDir,
TEAM_ROTATION_DATA_KEY: 'invite-join-mode-test-only-key',
TEAM_ROTATION_API_TOKEN: '',
TEAM_ROTATION_LOGIN_PASSWORD: 'daixuteam',
DISABLE_MAINTENANCE: 'true', OPENAI_CALLBACK_ENABLED: 'false', OPENAI_REQUEST_TIMEOUT_MS: '2000',
},
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
app.stdout.on('data', (chunk) => { output += chunk.toString(); });
app.stderr.on('data', (chunk) => { output += chunk.toString(); });
await waitForApp(baseUrl, app, () => output);
const login = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'daixuteam' }), signal: AbortSignal.timeout(5000),
});
assert.equal(login.status, 200, await login.text());
const cookie = login.headers.get('set-cookie')?.split(';', 1)[0];
assert.match(cookie || '', /^team_rotation_session=/);
const apiFetch = (pathname, options = {}) => fetch(`${baseUrl}${pathname}`, {
...options, headers: { ...options.headers, cookie },
});
return {
requests, pushRequests, unexpected, freeTokens, members, ownerToken,
async refill() {
const response = await apiFetch('/api/maintenance/refill', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motherId }), signal: AbortSignal.timeout(7000),
});
const result = await response.json();
assert.equal(response.status, 200, JSON.stringify(result));
return result;
},
async check() {
const response = await apiFetch('/api/maintenance/check', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motherId }), signal: AbortSignal.timeout(7000),
});
const result = await response.json();
assert.equal(response.status, 200, JSON.stringify(result));
return result;
},
async publicState() {
const response = await apiFetch('/api/state');
assert.equal(response.status, 200);
return response.json();
},
async manualJoin(childId, options = {}) {
const response = await apiFetch(`/api/children/${childId}/join`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motherId, workspaceId, ...options }), signal: AbortSignal.timeout(7000),
});
return { status: response.status, result: await response.json() };
},
async setJoinMode(mode) {
const response = await apiFetch('/api/settings', {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ joinMode: mode }), signal: AbortSignal.timeout(5000),
});
assert.equal(response.status, 200);
return response.json();
},
async updateTeam(body) {
const response = await apiFetch(`/api/mothers/${motherId}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify(body), signal: AbortSignal.timeout(5000),
});
return { status: response.status, result: await response.json() };
},
};
}
test('request mode keeps the same-domain Free candidate and skips a different domain', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { joinMode: 'request', childEmails: ['cross@icloud.com', 'same-local-different@gmail.com'] });
const result = await f.refill();
assert.equal(result.acceptedSeats, 1, JSON.stringify(result));
const requests = f.requests.filter((item) => item.path.endsWith('/invites/request'));
assert.equal(requests.length, 1);
assert.equal(requests[0].authorization, `Bearer ${f.freeTokens.get('same-local-different@gmail.com')}`);
assert.equal(f.requests.some((item) => item.method === 'POST' && item.path.endsWith('/invites')), false);
assert.deepEqual(f.unexpected, []);
});
test('request mode fills one ordinary and one premium remainder without changing seats per request', { timeout: 20_000 }, async (t) => {
const childEmails = ['first@gmail.com', 'second@gmail.com'];
const f = await fixture(t, {
joinMode: 'request', childEmails, defaultSeatType: 'default', paidDefault: 2, paidProlite: 1,
});
const result = await f.refill();
assert.equal(result.acceptedSeats, 2, JSON.stringify(result));
assert.equal(result.joined.length, 2, JSON.stringify(result));
const joinRequests = f.requests.filter((item) => item.method === 'POST' && item.path.endsWith('/invites/request'));
const approvals = f.requests.filter((item) => item.method === 'PATCH' && item.body?.accept_request === true);
assert.equal(joinRequests.length, 2);
assert.equal(approvals.length, 2);
assert.deepEqual(approvals.map((item) => item.body), [
{ role: 'standard-user', accept_request: true },
{ role: 'standard-user', accept_request: true },
]);
assert.equal(f.requests.some((item) => item.path.endsWith('/settings/default_seat_type')), false,
'refill must not temporarily change the Team default seat type');
assert.equal(f.requests.some((item) => item.method === 'PATCH' && item.body?.seat_type), false,
'refill must not assign a seat type on an individual request');
const joinedMembers = f.members.filter((member) => childEmails.includes(member.email));
assert.deepEqual(joinedMembers.map((member) => member.seat_type).sort(), ['default', 'prolite']);
const state = await f.publicState();
const recordedSeatTypes = state.children
.filter((child) => childEmails.includes(child.email))
.map((child) => child.joinedTeams.find((team) => team.team === workspaceId)?.seatType)
.sort();
assert.deepEqual(recordedSeatTypes, ['default', 'prolite']);
assert.deepEqual(f.unexpected, []);
});
test('a premium Team default prefers premium first and then falls back to ordinary capacity', { timeout: 20_000 }, async (t) => {
const childEmails = ['premium-first@gmail.com', 'ordinary-fallback@gmail.com'];
const f = await fixture(t, {
joinMode: 'request', childEmails, defaultSeatType: 'prolite', paidDefault: 2, paidProlite: 1,
});
const result = await f.refill();
assert.equal(result.acceptedSeats, 2, JSON.stringify(result));
assert.deepEqual(f.members.filter((member) => childEmails.includes(member.email)).map((member) => member.seat_type).sort(), ['default', 'prolite']);
assert.equal(f.requests.some((item) => item.path.endsWith('/settings/default_seat_type')), false);
assert.equal(f.requests.some((item) => item.method === 'PATCH' && item.body?.seat_type), false);
assert.deepEqual(f.unexpected, []);
});
test('request mode fails closed when the owner email has no valid domain', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { joinMode: 'request', ownerEmail: 'not-an-email', childEmails: ['child@gmail.com'] });
const result = await f.refill();
assert.equal(result.acceptedSeats, 0);
assert.deepEqual(result.joined, []);
assert.equal(f.requests.some((item) => item.path.includes('/invites')), false);
assert.deepEqual(f.unexpected, []);
});
test('manual request join rejects a cross-domain address before sending a request', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { joinMode: 'request', childEmails: ['cross@icloud.com'] });
const { status, result } = await f.manualJoin('child-0');
assert.equal(status, 409, JSON.stringify(result));
assert.equal(result.ok, false);
assert.equal(f.requests.some((item) => item.path.includes('/invites')), false);
assert.deepEqual(f.unexpected, []);
});
test('settings can switch the manual join path between invitation and domain-limited request', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { joinMode: 'request', childEmails: ['cross@icloud.com'] });
const inviteSetting = await f.setJoinMode('invite');
assert.equal(inviteSetting.settings.joinMode, 'invite');
const invited = await f.manualJoin('child-0', { approve: false });
assert.equal(invited.status, 202, JSON.stringify(invited.result));
assert.equal(invited.result.phase, 'invited');
const requestSetting = await f.setJoinMode('request');
assert.equal(requestSetting.settings.joinMode, 'request');
assert.equal((await f.publicState()).settings.joinMode, 'request');
const rejected = await f.manualJoin('child-0');
assert.equal(rejected.status, 409, JSON.stringify(rejected.result));
assert.equal(f.requests.filter((item) => item.method === 'POST' && item.path.endsWith('/invites')).length, 1);
assert.equal(f.requests.some((item) => item.path.endsWith('/invites/request')), false);
assert.equal(f.requests.some((item) => item.path.endsWith('/invites/accept')), false);
assert.deepEqual(f.unexpected, []);
});
test('invite mode creates a cross-domain invitation and accepts with the Free AT before Team JSON', { timeout: 20_000 }, async (t) => {
const email = 'cross@icloud.com';
const f = await fixture(t, { childEmails: [email, 'extra@different.test'], pushConfigured: true });
const result = await f.refill();
assert.equal(result.acceptedSeats, 1, JSON.stringify(result));
assert.equal(result.joined.length, 1, JSON.stringify(result));
const invited = f.requests.find((item) => item.method === 'POST' && item.path.endsWith('/invites'));
assert.deepEqual(invited.body, { email_addresses: [email], role: 'standard-user', resend_emails: false });
assert.equal(invited.authorization, `Bearer ${f.ownerToken}`);
const accepted = f.requests.find((item) => item.method === 'POST' && item.path.endsWith('/invites/accept'));
assert.ok(accepted);
assert.equal(accepted.authorization, `Bearer ${f.freeTokens.get(email)}`);
assert.equal(accepted.bodyText, '');
assert.equal(f.requests.some((item) => item.method === 'PATCH'), false, 'default seats skip the seat assignment PATCH');
const exchange = f.requests.find((item) => item.path === '/api/auth/session');
assert.ok(exchange, 'confirmed membership proceeds to Team JSON exchange');
assert.ok(f.requests.indexOf(exchange) > f.requests.indexOf(accepted));
assert.equal(f.requests.filter((item) => item.path.endsWith('/invites/accept')).length, 1, 'one open seat cannot admit the extra candidate');
assert.equal(result.sub2apiPush?.pushed, 1, JSON.stringify(result.sub2apiPush));
const created = f.pushRequests.filter((item) => item.method === 'POST' && item.path === '/api/v1/admin/accounts');
assert.equal(created.length, 1);
assert.equal(created[0].body.credentials.chatgpt_account_id, workspaceId);
assert.deepEqual(created[0].body.group_ids, [11]);
const state = await f.publicState();
assert.equal(state.settings.joinMode, 'invite');
assert.equal(state.children.find((child) => child.email === email)?.joinedTeams.some((team) => team.team === workspaceId), true);
assert.deepEqual(f.unexpected, []);
});
test('invite mode reuses an exact email invitation and relies on the Team premium default', { timeout: 20_000 }, async (t) => {
const email = 'premium@other.test';
const f = await fixture(t, {
childEmails: [email], defaultSeatType: 'prolite', paidDefault: 1, paidProlite: 1,
invitationRows: [
{ id: 'wrong-invite', email_address: 'unrelated@other.test' },
{ id: 'existing-invite', email_address: email.toUpperCase() },
],
});
const result = await f.refill();
assert.equal(result.acceptedSeats, 1, JSON.stringify(result));
assert.equal(f.requests.some((item) => item.method === 'POST' && item.path.endsWith('/invites')), false);
const accepted = f.requests.find((item) => item.method === 'POST' && item.path.endsWith('/invites/accept'));
assert.ok(accepted);
assert.equal(f.requests.some((item) => item.method === 'PATCH' && item.path.includes('/invites/')), false);
assert.equal(f.requests.some((item) => item.path.endsWith('/settings/default_seat_type')), false);
assert.equal(f.members.find((member) => member.email === email)?.seat_type, 'prolite');
assert.deepEqual(f.unexpected, []);
});
test('Team management saves a changed remote default seat type exactly once', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, {
defaultSeatType: 'default', paidDefault: 1, paidProlite: 1,
});
const saved = await f.updateTeam({ defaultSeatType: 'prolite' });
assert.equal(saved.status, 200, JSON.stringify(saved.result));
const settings = f.requests.filter((item) => item.method === 'POST'
&& item.path === `/backend-api/accounts/${workspaceId}/settings/default_seat_type`);
assert.equal(settings.length, 1);
assert.deepEqual(settings[0].body, { value: 'prolite' });
assert.equal(settings[0].authorization, `Bearer ${f.ownerToken}`);
const state = await f.publicState();
assert.equal(state.mothers[0].defaultSeatType, 'prolite');
assert.deepEqual(f.unexpected, []);
});
test('Team management keeps the local default when the remote setting fails', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { defaultSeatType: 'default', defaultSeatStatus: 422 });
const saved = await f.updateTeam({ defaultSeatType: 'prolite', teamName: 'Must Not Persist' });
assert.equal(saved.status, 422, JSON.stringify(saved.result));
const settings = f.requests.filter((item) => item.method === 'POST'
&& item.path === `/backend-api/accounts/${workspaceId}/settings/default_seat_type`);
assert.equal(settings.length, 1);
assert.deepEqual(settings[0].body, { value: 'prolite' });
assert.equal(f.requests.some((item) => item.body?.accept_request === true), false);
const state = await f.publicState();
assert.equal(state.mothers[0].defaultSeatType, 'default');
assert.equal(state.mothers[0].teamName, 'Invitation Fixture');
assert.deepEqual(f.unexpected, []);
});
test('held seats block invitations before any join side effect', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { seatType: 'prolite', paidDefault: 1, paidProlite: 1, heldProlite: 1 });
const result = await f.refill();
assert.equal(result.acceptedSeats, 0);
assert.equal(result.seatsOpen, 0);
assert.equal(f.requests.some((item) => item.path.includes('/invites')), false);
assert.deepEqual(f.unexpected, []);
});
test('the total seat limit blocks invitations even when a seat type appears available', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, {
paidDefault: 1, paidProlite: 1,
initialMembers: [{ id: 'other-member', email: 'other@gmail.com', role: 'standard-user', seat_type: 'default' }],
});
const result = await f.refill();
assert.equal(result.acceptedSeats, 0);
assert.equal(result.seatsOpen, 0);
assert.equal(f.requests.some((item) => item.path.includes('/invites')), false);
assert.deepEqual(f.unexpected, []);
});
test('invitation-only action leaves the Free account outside the Team and reserves no seat', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { childEmails: ['cross@icloud.com'] });
const { status, result } = await f.manualJoin('child-0', { approve: false });
assert.equal(status, 202, JSON.stringify(result));
assert.equal(result.phase, 'invited');
assert.equal(f.requests.filter((item) => item.method === 'POST' && item.path.endsWith('/invites')).length, 1);
assert.equal(f.requests.some((item) => item.path.endsWith('/invites/accept')), false);
assert.equal(f.requests.some((item) => item.path === '/api/auth/session'), false);
const state = await f.publicState();
assert.equal(state.mothers[0].seatClaimsCount, 0);
assert.equal(state.children[0].joinedTeams.some((team) => team.team === workspaceId && team.status === 'active'), false);
assert.deepEqual(f.unexpected, []);
});
test('failed invitation acceptance does not start Team JSON or Sub2API push', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { acceptStatus: 403, pushConfigured: true });
const result = await f.refill();
assert.equal(result.joined.length, 0);
assert.equal(result.acceptedSeats, 0);
assert.equal(f.requests.filter((item) => item.path.endsWith('/invites/accept')).length, 1);
assert.equal(f.requests.some((item) => item.path === '/api/auth/session'), false);
assert.equal(f.pushRequests.length, 0);
assert.deepEqual(f.unexpected, []);
});
test('HTTP 200 acceptance with success:false is not treated as a joined member', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { acceptPayload: { success: false }, pushConfigured: true });
const result = await f.refill();
assert.equal(result.joined.length, 0);
assert.equal(result.acceptedSeats, 0);
assert.equal(f.requests.some((item) => item.path === '/api/auth/session'), false);
assert.equal(f.pushRequests.length, 0);
assert.deepEqual(f.unexpected, []);
});
test('unconfirmed acceptance retains the seat claim and never starts Team JSON or duplicate push', { timeout: 20_000 }, async (t) => {
const f = await fixture(t, { confirmMember: false, pushConfigured: true });
const first = await f.refill();
assert.equal(first.joined.length, 0);
assert.equal(f.requests.filter((item) => item.path.endsWith('/invites/accept')).length, 1);
assert.equal(f.requests.some((item) => item.path === '/api/auth/session'), false);
assert.equal(f.pushRequests.length, 0);
const state = await f.publicState();
assert.equal(state.mothers[0].seatClaimsCount, 1);
await f.refill();
assert.equal(f.requests.filter((item) => item.method === 'POST' && item.path.endsWith('/invites')).length, 1);
assert.equal(f.requests.filter((item) => item.path.endsWith('/invites/accept')).length, 1);
assert.equal(f.requests.some((item) => item.path === '/api/auth/session'), false);
assert.equal(f.pushRequests.length, 0);
assert.deepEqual(f.unexpected, []);
});