From bca25f8418e394e0681ee305fbf349693a0ceb5d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 18:28:21 +0200 Subject: [PATCH] feat(console): let an administrator set a guild up without leaving the browser PR #149 shipped the routes and nothing rendered them: setting a guild up meant running /setup in Discord or calling three endpoints by hand. This is Server Setup, first entry of the Admin View, walking the invitation, the server, what to record, and what came of it. The console writes an intent rather than configuration -- api holds no Discord token and never will -- so the page's job is to express three properties of that mechanism that the payload does not state on its own. A failure is terminal, so the page says there is no retry and offers another ask instead of a wait. The newest ask wins, so a superseded request is never drawn in the failure colour and a request that is not the one this browser sent says so. And bot.has_arrived is what separates "this server has no voice channels" from "nobody has looked yet", so the picker draws four states rather than one empty list. Two things reading the applier decided rather than the brief. Setting up adds to voice_channel_ids and never removes from it, so already-recorded channels are ticked and disabled instead of offering a control that would do nothing. And a stored channel the mirror cannot resolve is never carried into a request: the applier refuses a channel it cannot see, one refusal settles the whole intent as failed, and a guild with one deleted room would otherwise have every request it ever made fail over it. Every decision is in app/utils/onboarding.ts and tested there; the page keeps layout, request plumbing and the three-second poll, which stops on settlement, on unmount, and after five minutes of a bot that never arrives. --- console/app/pages/admin/onboarding.vue | 973 +++++++++++++++++++++++ console/app/utils/navigation.ts | 11 + console/app/utils/onboarding.ts | 574 +++++++++++++ console/i18n/README.md | 9 + console/i18n/locales/de.json | 93 ++- console/i18n/locales/en.json | 93 ++- console/test/adminOnboardingPage.spec.ts | 515 ++++++++++++ console/test/navigation.spec.ts | 11 + console/test/onboarding.spec.ts | 434 ++++++++++ docs/operations.md | 5 + 10 files changed, 2716 insertions(+), 2 deletions(-) create mode 100644 console/app/pages/admin/onboarding.vue create mode 100644 console/app/utils/onboarding.ts create mode 100644 console/test/adminOnboardingPage.spec.ts create mode 100644 console/test/onboarding.spec.ts diff --git a/console/app/pages/admin/onboarding.vue b/console/app/pages/admin/onboarding.vue new file mode 100644 index 0000000..6fc4387 --- /dev/null +++ b/console/app/pages/admin/onboarding.vue @@ -0,0 +1,973 @@ + + + diff --git a/console/app/utils/navigation.ts b/console/app/utils/navigation.ts index 5c208ff..cda38de 100644 --- a/console/app/utils/navigation.ts +++ b/console/app/utils/navigation.ts @@ -87,6 +87,17 @@ export const ADMIN_VIEW: NavSection = { labelKey: 'nav.adminView', adminOnly: true, entries: [ + { + to: '/admin/onboarding', + labelKey: 'nav.onboarding', + // A door with an arrow going into it: a bot being let into a server, + // which is the one step of this page that happens anywhere else. + // Deliberately not a cog — the cog is Bot Settings, and this page is + // not a second place to configure a guild but the place where a + // guild becomes configurable at all. + icon: 'M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5v-2H5V5h5V3Zm5.6 3.6-1.4 1.4 3 3H8v2h9.2l-3 3 1.4 1.4L21 12l-5.4-5.4Z', + adminOnly: true, + }, { to: '/admin/bot-settings', labelKey: 'nav.botSettings', diff --git a/console/app/utils/onboarding.ts b/console/app/utils/onboarding.ts new file mode 100644 index 0000000..3d475b8 --- /dev/null +++ b/console/app/utils/onboarding.ts @@ -0,0 +1,574 @@ +/** + * Setting a guild up from the console: what the answer means, and what to say. + * + * `api` holds no Discord token and never will (Spec 13.2), so the console + * cannot create the consent role, deny `Speak` to `@everyone` or register + * the command tree. It writes an **intent** instead, and the bot's + * ten-second reconcile tick makes it true and writes back what happened. + * `docs/operations.md` §6.2.14 is the contract; this module is the part of + * it that has to be said in an interface. + * + * Three properties of that mechanism decide everything in this file, and + * none of them can be read off the payload by somebody who has not been + * told: + * + * - **A failure is terminal.** The tick runs six times a minute forever, so + * an intent left unapplied after failing would retry a permission error + * against Discord's rate limiter just as often. One attempt settles it. + * There is no back-off, no retry and nothing to wait for: an + * administrator who has fixed the permission **asks again**, and that is + * a new row. So {@link reportRequest} says so on every failure rather + * than leaving a page to render "failed" beside a spinner. + * - **The newest ask wins, outright.** Two administrators thirty seconds + * apart leave two rows; the bot applies the newer and settles the older + * as `superseded` **without acting on it**. Nothing went wrong to a + * superseded request, and a page that drew it in the failure colour would + * send somebody to check a permission that was never tested. It is a + * `neutral` tone here, and it says the words. + * - **`pending` for more than a tick means the bot is not there.** A guild + * the bot has not joined has no gateway object to iterate, so its intents + * are never attempted. `bot.has_arrived` says which of the two it is, and + * {@link pickerState} keys off exactly that: an empty channel list means + * "this server has no voice channels" only once something has been + * mirrored, and means "nobody has looked yet" until then. One of those + * sends somebody to Discord to make a channel and the other sends them + * hunting for a bug that is not there. + * + * As everywhere in `app/utils`, the sentences are translation keys rather + * than prose, and a decided sentence is a {@link Message}. See + * `i18n/README.md`. + */ +import { type NamedRow, parseIdList, resolveChoice } from '~/utils/directory' +import type { Message } from '~/utils/message' + +/* -------------------------------------------------------------------- */ +/* The four statuses */ +/* -------------------------------------------------------------------- */ + +/** No outcome has been written: the bot has not reached this row yet. */ +export const PENDING = 'pending' + +/** The bot did what was asked. */ +export const APPLIED = 'applied' + +/** The bot tried and could not, and `error` says what Discord answered. + * Terminal: there is no second attempt. */ +export const FAILED = 'failed' + +/** The bot never tried. A newer request replaced this one before the tick + * reached either. Terminal like the other two, and **not a failure**. */ +export const SUPERSEDED = 'superseded' + +/** + * Whether the bot has finished with this row, whatever it did. + * + * Asked of the string rather than of a union on purpose. `outcome` is text + * in the database rather than an enum precisely so that a value this build + * has never seen is a row a reader can ignore instead of a write that fails + * inside a reconcile tick — and a console that narrowed it back down to + * four would hand that property back. Anything that is not `pending` is + * settled, including a word written by a newer bot than this console. + */ +export function isSettled(status: string): boolean { + return status !== PENDING +} + +/* -------------------------------------------------------------------- */ +/* Reading what the API sent */ +/* -------------------------------------------------------------------- */ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** `null` stays `null`; anything else becomes the string it prints as. + * **Every Discord id is a string and stays one**: a snowflake exceeds + * `Number.MAX_SAFE_INTEGER`, so anything that round-trips through a + * JavaScript number hands back an id ending in other digits. */ +function asText(value: unknown): string | null { + if (value === null || value === undefined) return null + return typeof value === 'string' ? value : String(value) +} + +export interface SetupRequestView { + /** A row id, and a string like every other id this API sends. */ + id: string + /** `pending`, or whatever the bot wrote when it settled the row. Not + * narrowed to a union — see {@link isSettled}. */ + status: string + /** The Discord id of whoever asked. */ + requestedBy: string + requestedAt: string | null + /** The list as the console offered it, so the same boxes can be ticked + * again without parsing a stored format. */ + channelIds: string[] + consentRoleName: string | null + /** When the bot finished with it, however it finished. */ + settledAt: string | null + /** Free text the bot composed for a person to act on. Rendered, never + * keyed off. */ + error: string | null +} + +export interface SetupState { + guildId: string | null + /** + * Whether anything about this guild has ever been mirrored. + * + * The field the channel picker depends on, and the only thing that + * separates "this server has no voice channels" from "nobody has looked + * yet". Defaults to `false` for a payload that does not say, because + * "not known to have arrived" is the honest reading of silence and it is + * the one that makes the page wait rather than the one that makes it + * claim a server has no rooms. + */ + botHasArrived: boolean + botSeenAt: string | null + /** The most recent thing anybody asked for, settled or not. */ + request: SetupRequestView | null +} + +export function parseSetupState(payload: unknown): SetupState { + const bot = isRecord(payload) && isRecord(payload.bot) ? payload.bot : {} + return { + guildId: isRecord(payload) ? asText(payload.guild_id) : null, + botHasArrived: bot.has_arrived === true, + botSeenAt: asText(bot.seen_at), + request: parseRequest(isRecord(payload) ? payload.request : null), + } +} + +function parseRequest(payload: unknown): SetupRequestView | null { + if (!isRecord(payload)) return null + const id = asText(payload.id) + if (!id) return null + const channels = Array.isArray(payload.channel_ids) ? payload.channel_ids : [] + return { + id, + // A row with no status at all is treated as still pending: it is the + // reading that keeps the page waiting rather than the one that + // declares an outcome nobody wrote. + status: asText(payload.status) ?? PENDING, + requestedBy: asText(payload.requested_by) ?? '', + requestedAt: asText(payload.requested_at), + channelIds: channels.map((each) => asText(each)).filter((each): each is string => Boolean(each)), + consentRoleName: asText(payload.consent_role_name), + settledAt: asText(payload.settled_at), + error: asText(payload.error), + } +} + +export interface BotInvite { + /** The `bot`-scope authorize URL, or `null` when this deployment has no + * `STURNUS_DISCORD_CLIENT_ID`. `null` is a configuration fact and not a + * failure, and the page says which. */ + url: string | null + /** The permission bitmask, sent even when there is no link: it is what + * somebody ticks if they build the link by hand in Discord's own URL + * generator instead. */ + permissions: string | null + scopes: string[] +} + +export function parseInvite(payload: unknown): BotInvite { + const scopes = isRecord(payload) && Array.isArray(payload.scopes) ? payload.scopes : [] + return { + url: isRecord(payload) ? asText(payload.url) : null, + permissions: isRecord(payload) ? asText(payload.permissions) : null, + scopes: scopes.map((each) => asText(each)).filter((each): each is string => Boolean(each)), + } +} + +/* -------------------------------------------------------------------- */ +/* What the channel picker is actually looking at */ +/* -------------------------------------------------------------------- */ + +/** + * Which of four things an empty channel list is. + * + * The distinction this whole page exists to draw. `waiting` and `empty` + * both render as no channels to tick, and they are opposite instructions: + * one says wait ten seconds, the other says go and make a voice channel. + * `unreadable` is neither — the mirror is there and this console could not + * read it — and rendering it as either would be a claim about a server + * nobody has looked at. + */ +export type PickerState = 'waiting' | 'unreadable' | 'empty' | 'ready' + +export function pickerState(input: { + botHasArrived: boolean + directoryFailed: boolean + channelCount: number +}): PickerState { + // Asked first, and before the failure: `has_arrived` is false exactly + // while nothing has been mirrored, so a directory call that came back + // empty for that guild answered correctly and there is nothing to + // report as broken. + if (!input.botHasArrived) return 'waiting' + if (input.directoryFailed) return 'unreadable' + return input.channelCount === 0 ? 'empty' : 'ready' +} + +export interface StoredChannels { + /** Ids the guild already records that the mirror can name. */ + recorded: string[] + /** + * Ids the guild records that the mirror has no row for. + * + * Never submitted, and that is not tidiness. The applier refuses a + * channel a request names that it cannot see — "so it was not added" — + * and a refusal is a `problem`, and one problem settles the whole intent + * as `failed`. Carrying a stale stored id into every request would make + * every request from that guild fail for a channel nobody asked about. + */ + stale: string[] +} + +/** + * What this guild already records, split by whether the mirror can name it. + * + * `resolveChoice` rather than a lookup written here: how an unresolved + * snowflake is presented is one decision this console has already taken, + * in `~/utils/directory`, and a second answer to it is a second thing to + * keep in step. + */ +export function storedChannels( + stored: string | null | undefined, + channels: readonly NamedRow[], +): StoredChannels { + const recorded: string[] = [] + const stale: string[] = [] + for (const id of parseIdList(stored)) { + if (resolveChoice(channels, id).resolved) recorded.push(id) + else stale.push(id) + } + return { recorded, stale } +} + +/** + * The list a submission names: what is already recorded, plus what was ticked. + * + * The union rather than the ticks alone, because a setup request **adds** + * to the stored list and never replaces it (`setup_apply._apply_one`). + * Sending only the new ticks would work identically — the applier unions + * them itself — but it would leave the payload saying something other than + * what the guild will record, and this page shows the payload back. + * + * The consequence for the interface is the part worth stating: **unticking + * an already-recorded channel here removes nothing.** That is why the page + * renders those rows ticked and disabled rather than offering a control + * that would do nothing. Removing a channel is `voice_channel_ids` on Bot + * Settings, and the page says so beside them. + */ +export function submittedChannels( + stored: StoredChannels, + ticked: readonly string[], +): string[] { + const ids = [...stored.recorded] + for (const id of ticked) if (!ids.includes(id)) ids.push(id) + return ids +} + +/* -------------------------------------------------------------------- */ +/* What the form will and will not send */ +/* -------------------------------------------------------------------- */ + +/** Discord's own limit on a role name, and the API's. Checked here as well + * so that somebody is told while they are still typing rather than by a + * request that is accepted, sits pending for a tick and comes back + * refused. */ +export const MAX_ROLE_NAME = 100 + +export interface SetupDraft { + /** Every channel the request names — {@link submittedChannels}' answer, + * not the ticks on their own. */ + channelIds: readonly string[] + /** What to call the consent role. Blank means "do not name one", which + * keeps whatever role the guild already has. */ + consentRoleName: string +} + +/** + * Why this draft would be refused, or `null` if it would not be. + * + * This page never offers an action it knows will fail — the same rule the + * destinations page follows — so the submit button is disabled and this + * sentence sits beside it. + */ +export function draftProblem(draft: SetupDraft): Message | null { + if (draft.channelIds.length === 0) return { key: 'admin.onboarding.needChannel' } + if (draft.consentRoleName.trim().length > MAX_ROLE_NAME) { + return { key: 'admin.onboarding.roleTooLong', params: { limit: String(MAX_ROLE_NAME) } } + } + return null +} + +export interface SetupRequestBody { + channel_ids: string[] + consent_role_name: string | null +} + +/** + * The body to POST. + * + * A blank name becomes `null` rather than `""`: absent and null both mean + * "do not name a role", which leaves whatever role the guild already has, + * and omitting something must never be the destructive path (Spec 10.1). + * The API refuses a blank string outright, which is the same decision from + * the other side. + */ +export function requestBody(draft: SetupDraft): SetupRequestBody { + const name = draft.consentRoleName.trim() + return { channel_ids: [...draft.channelIds], consent_role_name: name === '' ? null : name } +} + +/* -------------------------------------------------------------------- */ +/* What the answer means */ +/* -------------------------------------------------------------------- */ + +/** + * How a request should read, in words as well as in colour. + * + * Six tones and not four, because `pending` is two different situations — + * the bot is coming, or the bot is not there — and `superseded` is not a + * failure however much it looks like one in a list of outcomes. Every tone + * carries a badge word as well as a colour: a state a page communicates + * only by being red is a state it has not communicated. + */ +export type RequestTone = 'waiting' | 'stalled' | 'good' | 'bad' | 'neutral' | 'unknown' + +export function requestTone(status: string, botHasArrived: boolean): RequestTone { + switch (status) { + case PENDING: + // The whole of the third property, in one line. A pending row is not + // slow; it is either about to be picked up, or sitting in a guild + // with no bot in it to pick it up. + return botHasArrived ? 'waiting' : 'stalled' + case APPLIED: + return 'good' + case FAILED: + return 'bad' + case SUPERSEDED: + return 'neutral' + default: + // An outcome written by a newer bot than this console. Rendered as + // itself rather than guessed at. + return 'unknown' + } +} + +export interface RequestReport { + tone: RequestTone + /** The word in the badge. Never the only thing that says what happened. */ + badge: Message + heading: Message + /** Sentences under the heading, in order, each one a decision. */ + notes: Message[] + /** The bot's own words, verbatim and multi-line, or `null`. Rendered + * rather than interpreted: it names which channel, which permission and + * what to do about it, and no key here could say that. */ + error: string | null +} + +export interface ReportContext { + /** The signed-in person's Discord id, for telling "you asked" from + * "somebody else did". */ + viewer: string | null + /** The id of the request this browser submitted, if it submitted one. + * See {@link RequestReport} — this is how the supersede rule is made + * visible while it is happening. */ + submitted: string | null +} + +/** + * What to say about the request a guild's setup is currently waiting on. + * + * `null` when nobody has ever asked: there is no panel, rather than a panel + * saying nothing has happened. + * + * **The replaced note comes first.** `GET` answers with the guild's newest + * request, which after a colleague pressed the button thirty seconds later + * is *theirs*. Everything else on the panel then describes a request this + * reader did not make, and reading it as their own is how somebody + * concludes their channel list was applied when another one was. The + * status is still the guild's answer — that part is honest and stays — but + * whose answer it is has to be said before it, not after. + */ +export function reportRequest(state: SetupState, context: ReportContext): RequestReport | null { + const request = state.request + if (!request) return null + + const tone = requestTone(request.status, state.botHasArrived) + const notes: Message[] = [] + + if (context.submitted !== null && context.submitted !== request.id) { + notes.push({ key: 'admin.onboarding.replacedYours' }) + } + + switch (tone) { + case 'waiting': + notes.push({ key: 'admin.onboarding.waitingNote' }) + break + case 'stalled': + // Not "this is taking a while". The payload says outright that + // nothing about this guild has ever been mirrored, and a page that + // offered patience instead of that fact would have somebody waiting + // on a tick that will never reach them. + notes.push({ key: 'admin.onboarding.stalledNote' }) + break + case 'good': + notes.push({ key: 'admin.onboarding.appliedNote' }, { key: 'admin.onboarding.appliedNext' }) + break + case 'bad': + notes.push({ key: 'admin.onboarding.failedTerminal' }, { key: 'admin.onboarding.roleOrder' }) + break + case 'neutral': + notes.push({ key: 'admin.onboarding.supersededNote' }) + break + default: + notes.push({ key: 'admin.onboarding.unknownNote' }) + } + + return { + tone, + badge: badgeFor(tone, request.status), + heading: { key: `admin.onboarding.heading.${tone}` }, + notes, + // Only a failure carries one, and only a failure's is worth the room. + // An `error` on any other outcome would be a row written by hand. + error: tone === 'bad' ? request.error : null, + } +} + +function badgeFor(tone: RequestTone, status: string): Message { + return tone === 'unknown' + ? { key: 'admin.onboarding.status.unknown', params: { status } } + : { key: `admin.onboarding.status.${tone}` } +} + +/** + * Who asked, as a sentence. + * + * "You" where it was this reader, because the supersede rule turns on + * there being two administrators and the first question anybody has of a + * request they did not expect is whose it is. Otherwise the mirror's name + * for them, and where the mirror has no row — it holds the consent role's + * and the admin role's members and nobody else — the bare id, which is + * `~/utils/directory`'s single answer to an unresolved snowflake. + */ +export function requesterLabel( + request: SetupRequestView, + viewer: string | null, + members: readonly NamedRow[], +): Message { + if (viewer !== null && viewer === request.requestedBy) { + return { key: 'admin.onboarding.askedByYou' } + } + const choice = resolveChoice(members, request.requestedBy) + return choice.resolved + ? { key: 'admin.onboarding.askedBy', params: { who: choice.label } } + : { key: 'admin.onboarding.askedByUnresolved', params: { id: choice.label } } +} + +/** + * What pressing the button again will do, said before it is pressed. + * + * The API deliberately accepts a second request while the first is still + * pending — refusing one would leave somebody who mistyped a channel unable + * to correct it until a tick had passed, and would lock a guild whose bot + * has not arrived out of being set up at all. So the form stays live, and + * this is the sentence that keeps that from being a surprise. + */ +export function resubmitNote(state: SetupState): Message | null { + const request = state.request + if (!request) return null + if (!isSettled(request.status)) return { key: 'admin.onboarding.resubmitReplaces' } + if (request.status === FAILED) return { key: 'admin.onboarding.resubmitAfterFailure' } + return { key: 'admin.onboarding.resubmitAgain' } +} + +/* -------------------------------------------------------------------- */ +/* Watching it happen */ +/* -------------------------------------------------------------------- */ + +/** + * Three seconds, against a tick that runs every ten. + * + * Fast enough that the answer arrives within a few seconds of the bot + * writing it, and slow enough that one administrator watching one guild is + * twenty reads a minute of a single row. + */ +export const POLL_INTERVAL_MS = 3000 + +/** + * How many times to ask before giving up and offering a button instead. + * + * A hundred, which is five minutes. The wait this bounds is not the tick — + * that is ten seconds — but a human one: `has_arrived` stays false until + * somebody opens Discord and adds the bot, and a tab left open on that + * state would otherwise poll for as long as the browser is running. Five + * minutes is long enough that nobody who is actually doing it hits the + * bound, and the page then says it has stopped rather than pretending to + * still be watching. + */ +export const POLL_LIMIT = 100 + +/** + * Whether there is still something to watch for. + * + * Two reasons, not one. A pending request is the obvious one. The other is + * a guild the bot has not reached: the whole page is inert until it + * arrives — no channels to tick, no request that can be attempted — and + * polling is what makes the page come alive on its own when it does, + * rather than leaving somebody to guess when to press Refresh. + */ +export function shouldPoll(state: SetupState | null, attempts: number): boolean { + if (state === null || attempts >= POLL_LIMIT) return false + return !state.botHasArrived || state.request?.status === PENDING +} + +/* -------------------------------------------------------------------- */ +/* Where the requests are */ +/* -------------------------------------------------------------------- */ + +export const INVITE_PATH = '/invite' + +export function setupPath(guildId: string): string { + return `/guilds/${guildId}/setup` +} + +/* -------------------------------------------------------------------- */ +/* When a call does not work */ +/* -------------------------------------------------------------------- */ + +/** + * Why a call failed, from its status and nothing else. + * + * The status is all there is: `apiError.sanitiseFetchError` keeps nothing + * else from a failed response, on purpose, so that no page can accidentally + * render an internal hostname out of a `$fetch` error. + * + * The 404 is the one worth reading twice. These routes answer 404 both for + * a guild that does not exist and for one this person does not administer, + * deliberately and identically — so the sentence says what is true of both + * without guessing which, and mentions the third case that actually + * produces it here: a bot that has been removed from the server since the + * page was opened. + */ +export function describeSetupError(error: unknown): Message { + const held = (error as { status?: unknown } | null)?.status + const status = typeof held === 'number' ? held : null + switch (status) { + case 400: + return { key: 'admin.onboarding.errorRefused' } + case 401: + return { key: 'admin.onboarding.errorSession' } + case 404: + return { key: 'admin.onboarding.errorGone' } + case 0: + case null: + return { key: 'admin.onboarding.errorUnreachable' } + default: + return { key: 'admin.onboarding.errorStatus', params: { status: String(status) } } + } +} diff --git a/console/i18n/README.md b/console/i18n/README.md index 77db10e..141fbcc 100644 --- a/console/i18n/README.md +++ b/console/i18n/README.md @@ -44,6 +44,7 @@ matches the file that renders it: | `recordings.*` | `pages/recordings/*` and the components under them | | `calendar.*` | `pages/calendar.vue`, the heatmap and the timeline | | `settings.*` | `pages/settings.vue` — a person's own settings | +| `admin.onboarding.*` | `pages/admin/onboarding.vue`, `utils/onboarding.ts` | | `admin.settings.*` | `pages/admin/bot-settings.vue` | | `admin.destinations.*` | `pages/admin/destinations.vue`, `utils/exportTargets.ts`, the two `ExportTarget*` components | | `admin.consents.*` | `pages/admin/consents.vue` | @@ -77,6 +78,14 @@ scope on a roster row — so that the sweep has less to do rather than more. New strings on that page go through `$t` from now on; the existing ones move when the sweep reaches them. +`admin.onboarding.*` is complete from the start for the same reason +`admin.destinations.*` is, and nothing under it will ever be reached by the +sweep. Three of its sentences carry a value the API composed rather than one +this console decided — the bot's own failure text, a channel's name, a +Discord id — and each of those is rendered beside a translated sentence +instead of inside one, because a sentence with somebody else's prose glued +into the middle of it cannot be re-ordered in German. + `admin.destinations.*` is the one administrative namespace that is complete from the start, because the page under it is new: nothing on `/admin/destinations` was ever hard-coded English, so there is nothing there diff --git a/console/i18n/locales/de.json b/console/i18n/locales/de.json index 8c5c15e..fa0f22b 100644 --- a/console/i18n/locales/de.json +++ b/console/i18n/locales/de.json @@ -24,7 +24,8 @@ "queue": "Warteschlange", "reporting": "Auswertung", "consents": "Einwilligungen", - "destinations": "Ziele" + "destinations": "Ziele", + "onboarding": "Server-Einrichtung" }, "auth": { "signIn": "Anmelden", @@ -741,6 +742,96 @@ "fallbackInUse": "Hier ist kein Ziel eingeschaltet, deshalb veröffentlicht dieser Server weiterhin dorthin, wohin document_target in den Bot-Einstellungen zeigt: ein Outline-Dokument je Besprechung. Ein hier eingeschaltetes Ziel übernimmt das vollständig.", "fallbackReplaced": "Dieser Server veröffentlicht an das eine Ziel oben, und document_target in den Bot-Einstellungen wird dafür nicht mehr benutzt — die Ziele hier ersetzen jene Einstellung, statt sie zu ergänzen. | Dieser Server veröffentlicht an die {count} Ziele oben, und document_target in den Bot-Einstellungen wird dafür nicht mehr benutzt — die Ziele hier ersetzen jene Einstellung, statt sie zu ergänzen.", "fallbackLink": "Bot-Einstellungen öffnen" + }, + "onboarding": { + "title": "Einen Server einrichten", + "intro": "Sturnus wird eingerichtet, indem der Bot darum gebeten wird. Diese Konsole besitzt kein Discord-Token und wird nie eines besitzen, also hält sie fest, was gelten soll; der nächste Durchlauf des Bots — etwa zehn Sekunden — setzt es um und trägt das Ergebnis zurück.", + "inviteHeading": "1. Den Bot in den Server holen", + "inviteBody": "Der einzige Schritt, der wirklich im Browser stattfindet, und der Schritt, auf den alle übrigen warten. Solange der Bot nicht im Server ist, spiegelt er nichts, und alles Weitere hat nichts anzuzeigen.", + "inviteAction": "Sturnus zu einem Server hinzufügen", + "inviteNewTab": "Öffnet Discord in einem neuen Tab", + "inviteFailed": "Der Einladungslink konnte nicht gelesen werden.", + "inviteMissingHeading": "Diese Installation hat keinen Einladungslink", + "inviteMissingBody": "Hier ist keine Discord-Anwendungs-ID hinterlegt, aus der sich ein Link bauen ließe. Stattdessen einen im URL-Generator von Discord mit den beiden Werten unten erzeugen — oder beim Betrieb dieser Installation {variable} setzen lassen.", + "inviteScopes": "Anzuhakende Scopes", + "invitePermissions": "Berechtigungen, als die Zahl aus dem Generator von Discord", + "roleOrderHeading": "Vor dem Verlassen von Discord: eine Rolle verschieben", + "roleOrder": "„Rollen verwalten“ in der Einladung genügt allein nicht. Die eigene Rolle des Bots muss in den Servereinstellungen → Rollen über der Einwilligungsrolle stehen, sonst verweigert Discord deren Anlage — und keine Berechtigungszahl kann das ausdrücken, weshalb auch ein Server mit sämtlichen erteilten Berechtigungen hier scheitern kann. Das geht zuletzt schief und lässt sich am leichtesten erledigen, solange Discord ohnehin offen ist.", + "serverHeading": "2. Den Server auswählen", + "whichServer": "Welcher Server", + "serversFailed": "Die Serverliste konnte nicht gelesen werden.", + "noneYetHeading": "Noch kein Server zum Einrichten", + "noneYetBody": "Ein Server erscheint hier, sobald der Bot ihm beigetreten ist und ihn einmal erfasst hat — etwa zehn Sekunden nach Annahme der Einladung.", + "noneYetRole": "Ist der Bot schon länger im Server, prüfen, ob das hier angemeldete Konto die Rolle besitzt, die {setting} benennt.", + "configureHeading": "3. Festlegen, was aufgezeichnet wird", + "configureBody": "Ein angehakter Kanal bedeutet zwei Berechtigungsausnahmen darin: „Sprechen“ für {'@'}everyone verboten, „Sprechen“ für die Einwilligungsrolle erlaubt. Dieses Paar ist der gesamte Einwilligungsschutz — es macht aus dem Aufgezeichnetwerden etwas, wofür man sich entscheidet, statt etwas, das einem widerfährt.", + "channelsLabel": "Aufzuzeichnende Kanäle", + "alreadyRecorded": "wird bereits aufgezeichnet", + "alreadyRecordedNote": "Was dieser Server bereits aufzeichnet, ist angehakt und lässt sich hier nicht abwählen, denn das Einrichten ergänzt diese Liste und nimmt nie etwas davon weg. Einen Kanal entfernt man über {setting} in den Bot-Einstellungen.", + "staleHeading": "{count} aufgezeichneter Kanal fehlt im Spiegel | {count} aufgezeichnete Kanäle fehlen im Spiegel", + "staleBody": "Der Bot sieht sie nicht, und eine Anfrage, die einen für den Bot unsichtbaren Kanal nennt, scheitert im Ganzen — deshalb lässt diese Anfrage sie weg. Bei Gelegenheit über {setting} in den Bot-Einstellungen entfernen.", + "waitingHeading": "Diesen Server hat noch niemand angesehen", + "waitingBody": "Es wurde noch nichts darüber gespiegelt, deshalb gibt es keine Kanalliste anzuzeigen. Das ist kein Server ohne Räume, sondern ein Server, den noch nichts gelesen hat. Die Seite fragt weiter nach und ergänzt sich von selbst.", + "emptyHeading": "Dieser Server hat keine Sprachkanäle", + "emptyBody": "Der Bot hat ihn erfasst und keine gefunden — das ist also der Server, wie er ist, und keine Liste, die nicht geladen hat. Einen Sprachkanal in Discord anlegen; er erscheint hier nach dem nächsten Durchlauf.", + "unreadableHeading": "Die Kanalnamen konnten nicht gelesen werden", + "unreadableBody": "Der Bot hat diesen Server erfasst, es gibt also eine Kanalliste; diese Konsole konnte sie nur nicht abrufen. Am Server selbst ist nichts falsch.", + "roleNameLabel": "Einwilligungsrolle", + "roleNameHint": "Leer lassen, um die vorhandene Rolle dieses Servers zu behalten oder Sturnus eine anlegen zu lassen, falls keine da ist. Ein Name verlangt genau die so benannte Rolle und legt sie an, wenn es keine gibt — verglichen wird exakt, denn „Aufgezeichnet“ und „aufgezeichnet“ sind in Discord zwei verschiedene Rollen.", + "roleNamePlaceholder": "Leer lassen, um nichts zu benennen", + "needChannel": "Mindestens einen Kanal anhaken. Eine Anfrage ohne Kanal wird abgelehnt.", + "roleTooLong": "Discord erlaubt höchstens {limit} Zeichen in einem Rollennamen.", + "submit": "Den Bot bitten, diesen Server einzurichten", + "submitting": "Wird angefragt …", + "requestHeading": "4. Was daraus wurde", + "loading": "Einrichtung dieses Servers wird gelesen", + "loadFailed": "Die Einrichtung dieses Servers konnte nicht gelesen werden.", + "status": { + "waiting": "wartet", + "stalled": "nicht begonnen", + "good": "erledigt", + "bad": "gescheitert", + "neutral": "ersetzt", + "unknown": "Ergebnis {status}" + }, + "heading": { + "waiting": "Angefragt, und es wird auf den Bot gewartet", + "stalled": "Daran wird vorerst nichts arbeiten", + "good": "Dieser Server ist eingerichtet", + "bad": "Der Bot konnte es nicht zu Ende bringen", + "neutral": "Diese Anfrage wurde ersetzt, bevor irgendetwas daran geschah", + "unknown": "Auf eine Weise abgeschlossen, für die diese Seite kein Wort hat" + }, + "waitingNote": "Der Bot nimmt neue Anfragen in seinem normalen Durchlauf auf, binnen etwa zehn Sekunden. Diese Seite wartet auf die Antwort.", + "stalledNote": "Über diesen Server wurde noch nie etwas gespiegelt, und das ist dieselbe Tatsache wie: der Bot ist nicht darin. Eine Anfrage in einem Server, dem der Bot nicht beigetreten ist, wird gar nicht erst versucht — sie wartet, ohne zu scheitern, bis der Bot ankommt. Schritt 1 ist es, worauf sie wartet.", + "appliedNote": "Die Einwilligungsrolle, die Sprechen-Ausnahmen in jedem angefragten Kanal und die Liste der aufgezeichneten Kanäle wurden alle geschrieben.", + "appliedNext": "Zwei Dinge entscheidet das Einrichten nie: wohin ein Protokoll veröffentlicht wird und auf welche Datenschutzerklärung sich die Einwilligung in diesem Server bezieht. Beides wird auf den Seiten unten gesetzt, und ohne beides ist die Aufzeichnung nicht fertig eingerichtet.", + "failedTerminal": "Ein Versuch schließt eine Anfrage ab, wie er auch ausgegangen ist — diese hier ist also vorbei, nichts läuft im Hintergrund nach, und es gibt keine Wartezeit abzusitzen. Beheben, was der Bericht unten benennt, und erneut anfragen: das ist eine neue Anfrage und wird als eigene festgehalten.", + "supersededNote": "Eine neuere Anfrage traf ein, bevor der Bot diese hier erreichte, deshalb wurde sie nie versucht und hat am Server nichts verändert. Es ist nichts schiefgegangen. Zwei Aussagen darüber, was gelten soll, ergänzen sich nicht, also gewinnt die neueste vollständig — beide nacheinander anzuwenden hätte auf der älteren geendet, und das wäre eine Korrektur, die von dem Fehler überschrieben wird, den sie korrigiert.", + "unknownNote": "Der Bot hat ein Ergebnis vermerkt, das es beim Schreiben dieser Seite noch nicht gab. Abgeschlossen ist es in jedem Fall; der hier laufende Bot ist neuer als diese Konsole.", + "replacedYours": "Das ist nicht die aus diesem Browser abgeschickte Anfrage. Danach hat jemand anderes angefragt, und die neueste Anfrage ist die, nach der der Bot handelt — was folgt, beschreibt also deren Verlauf.", + "askedByYou": "Eigene Anfrage", + "askedBy": "Angefragt von {who}", + "askedByUnresolved": "Angefragt von {id}, wofür der Spiegel dieses Servers keinen Namen hat", + "askedAt": "Angefragt {moment}", + "settledAt": "Abgeschlossen {moment}", + "askedChannelsLabel": "Angefragte Kanäle", + "askedRoleLabel": "Angefragte Einwilligungsrolle", + "askedRoleNone": "Keine benannt, womit die vorhandene Rolle des Servers bleibt", + "errorHeading": "Was der Bot berichtet hat, in seinen eigenen Worten", + "resubmitReplaces": "Eine erneute Anfrage ersetzt die laufende, statt sich dahinter einzureihen — der Bot wendet die neueste an und legt die übrigen beiseite. Einen vertippten Kanal zu korrigieren heißt also nicht, einen Durchlauf abwarten zu müssen.", + "resubmitAfterFailure": "Erneut anzufragen ist der Weg, einer behobenen Berechtigung einen weiteren Versuch zu geben. Von selbst wiederholt sich nichts.", + "resubmitAgain": "Eine erneute Anfrage schreibt einen neuen Eintrag, den der Bot im nächsten Durchlauf anwendet.", + "watchingLive": "Wird alle paar Sekunden geprüft", + "watchingStopped": "Die Prüfung wurde nach fünf Minuten beendet. Verloren ist nichts — unten erneut nachsehen.", + "refresh": "Jetzt nachsehen", + "toBotSettings": "Bot-Einstellungen", + "toDestinations": "Ziele", + "errorRefused": "Die API hat die Anfrage abgelehnt.", + "errorSession": "Diese Sitzung ist beendet. Bitte neu anmelden.", + "errorGone": "Dieser Server antwortet, als gäbe es ihn nicht — dieselbe Antwort gilt für einen Server, den hier niemand verwaltet. Prüfen, ob der Bot noch darin ist.", + "errorUnreachable": "Die API war nicht erreichbar.", + "errorStatus": "Die API hat mit {status} geantwortet." } }, "ui": { diff --git a/console/i18n/locales/en.json b/console/i18n/locales/en.json index be9297a..8fbb7af 100644 --- a/console/i18n/locales/en.json +++ b/console/i18n/locales/en.json @@ -24,7 +24,8 @@ "queue": "Queue", "reporting": "Reporting", "consents": "Consents", - "destinations": "Destinations" + "destinations": "Destinations", + "onboarding": "Server Setup" }, "auth": { "signIn": "Sign in", @@ -741,6 +742,96 @@ "fallbackInUse": "No destination here is switched on, so this server still publishes where document_target on Bot Settings points: one Outline document per meeting. Switching one on here takes over from that setting completely.", "fallbackReplaced": "This server publishes to the one destination above, and document_target on Bot Settings is no longer used for it — the destinations here replace that setting rather than adding to it. | This server publishes to the {count} destinations above, and document_target on Bot Settings is no longer used for them — the destinations here replace that setting rather than adding to it.", "fallbackLink": "Open Bot Settings" + }, + "onboarding": { + "title": "Setting a server up", + "intro": "Sturnus is set up by asking the bot to do it. This console holds no Discord token and never will, so it writes down what should be true; the bot's next pass — about ten seconds — makes it true and writes back what happened.", + "inviteHeading": "1. Put the bot in the server", + "inviteBody": "The one step that genuinely happens in a browser, and the step every other one waits on. Until the bot is in the server it mirrors nothing, so nothing below has anything to show.", + "inviteAction": "Add Sturnus to a server", + "inviteNewTab": "Opens Discord in a new tab", + "inviteFailed": "The invitation link could not be read.", + "inviteMissingHeading": "This deployment has no invitation link", + "inviteMissingBody": "No Discord application id is configured here, so there is nothing to build a link out of. Build one in Discord's own URL generator with the two values below, or ask whoever runs this deployment to set {variable}.", + "inviteScopes": "Scopes to tick", + "invitePermissions": "Permissions, as the number Discord's generator produces", + "roleOrderHeading": "Before closing Discord, drag one role", + "roleOrder": "Manage Roles in the invitation is not enough on its own. The bot's own role has to sit above the consent role in Server Settings → Roles, or Discord refuses to create it — and no permission bitmask can express that, so a server that granted every permission on the list can still fail here. It is the last thing to go wrong and the easiest to do while already in Discord.", + "serverHeading": "2. Choose the server", + "whichServer": "Which server", + "serversFailed": "The list of servers could not be read.", + "noneYetHeading": "No server to set up yet", + "noneYetBody": "A server appears here once the bot has joined it and swept it once, which takes about ten seconds after the invitation is accepted.", + "noneYetRole": "If the bot has been in the server for longer than that, check that the account signed in here holds the role {setting} names.", + "configureHeading": "3. Say what to record", + "configureBody": "Ticking a channel asks for two permission overwrites on it: Speak denied to {'@'}everyone, Speak allowed for the consent role. That pair is the whole of the consent protection — it is what makes being recorded something a person opts into rather than something that happens to them.", + "channelsLabel": "Channels to record", + "alreadyRecorded": "already recorded", + "alreadyRecordedNote": "What this server already records is ticked and cannot be unticked here, because setting up adds to that list and never takes anything off it. Removing a channel is {setting} on Bot Settings.", + "staleHeading": "{count} channel this server records is missing from the mirror | {count} channels this server records are missing from the mirror", + "staleBody": "The bot cannot see them, and a request naming a channel the bot cannot see fails as a whole — so this request leaves them out. Clear them from {setting} on Bot Settings when convenient.", + "waitingHeading": "Nobody has looked at this server yet", + "waitingBody": "Nothing about it has been mirrored, so there is no channel list to show. This is not a server without rooms; it is a server nothing has read. The page keeps checking, and fills in on its own.", + "emptyHeading": "This server has no voice channels", + "emptyBody": "The bot has swept it and found none — so this is the server as it is, not a list that has not loaded. Create a voice channel in Discord and it appears here after the next sweep.", + "unreadableHeading": "The channel names could not be read", + "unreadableBody": "The bot has swept this server, so it has a channel list; this console failed to fetch it. Nothing about the server is wrong.", + "roleNameLabel": "Consent role", + "roleNameHint": "Leave this empty to keep the role this server already has, or to let Sturnus create one where there is none. A name asks for the role called exactly that, and creates it if no such role exists — matching is exact, because \"Recorded\" and \"recorded\" are two different roles in Discord.", + "roleNamePlaceholder": "Leave empty to name nothing", + "needChannel": "Tick at least one channel. A request naming none is refused.", + "roleTooLong": "Discord allows at most {limit} characters in a role name.", + "submit": "Ask the bot to set this server up", + "submitting": "Asking…", + "requestHeading": "4. What came of it", + "loading": "Reading this server's setup", + "loadFailed": "This server's setup could not be read.", + "status": { + "waiting": "waiting", + "stalled": "not started", + "good": "done", + "bad": "failed", + "neutral": "replaced", + "unknown": "outcome {status}" + }, + "heading": { + "waiting": "Asked, and waiting for the bot", + "stalled": "Nothing is going to attempt this yet", + "good": "This server is set up", + "bad": "The bot could not finish", + "neutral": "This request was replaced before anything was done to it", + "unknown": "Settled in a way this page has no word for" + }, + "waitingNote": "The bot picks up new requests on its ordinary pass, within about ten seconds. This page is watching for the answer.", + "stalledNote": "Nothing about this server has ever been mirrored, which is the same fact as the bot not being in it. A request in a server the bot has not joined is never attempted at all — it waits, without failing, until the bot arrives. Step 1 is what it is waiting for.", + "appliedNote": "The consent role, the Speak overwrites on every channel asked for, and the recorded channel list were all written.", + "appliedNext": "Two things setting up never decides: where a protocol is published, and which privacy policy the consent in this server refers to. Both are set on the pages below, and recording is not finished until they are.", + "failedTerminal": "One attempt settles a request whichever way it went, so this one is over — nothing is retrying behind it, and there is no back-off to wait out. Fix what the report below names and ask again: that is a new request, recorded as its own.", + "supersededNote": "A newer request arrived before the bot reached this one, so this one was never attempted and nothing about the server was changed by it. Nothing went wrong. Two statements of what should be true do not add up, so the newest wins outright — applying both in order would have finished on the older one, which is a correction being overwritten by the mistake it corrected.", + "unknownNote": "The bot recorded an outcome this page was written before. It is settled, whatever it was; the bot running here is newer than this console.", + "replacedYours": "This is not the request submitted from this browser. Somebody asked afterwards, and the newest ask is the one the bot acts on — so what follows describes theirs.", + "askedByYou": "Asked by you", + "askedBy": "Asked by {who}", + "askedByUnresolved": "Asked by {id}, whom this server's mirror cannot put a name to", + "askedAt": "Asked {moment}", + "settledAt": "Settled {moment}", + "askedChannelsLabel": "Channels asked for", + "askedRoleLabel": "Consent role asked for", + "askedRoleNone": "None named, which keeps whatever role the server has", + "errorHeading": "What the bot reported, in its own words", + "resubmitReplaces": "Asking again while this one is pending replaces it rather than queueing behind it — the bot applies the newest and buries the rest. Correcting a mistyped channel does not mean waiting for a pass to finish.", + "resubmitAfterFailure": "Asking again is how a fixed permission gets another attempt. Nothing retries on its own.", + "resubmitAgain": "Asking again writes a new request, and the bot applies it on its next pass.", + "watchingLive": "Checking every few seconds", + "watchingStopped": "Checking stopped after five minutes. Nothing has been lost — press below to look again.", + "refresh": "Check now", + "toBotSettings": "Bot Settings", + "toDestinations": "Destinations", + "errorRefused": "The API refused the request.", + "errorSession": "This session has ended. Sign in again.", + "errorGone": "This server answers as though it does not exist, which is also the answer for a server nobody here administers — check that the bot is still in it.", + "errorUnreachable": "The API could not be reached.", + "errorStatus": "The API answered {status}." } }, "ui": { diff --git a/console/test/adminOnboardingPage.spec.ts b/console/test/adminOnboardingPage.spec.ts new file mode 100644 index 0000000..41b00a9 --- /dev/null +++ b/console/test/adminOnboardingPage.spec.ts @@ -0,0 +1,515 @@ +/** + * What the onboarding page does that no unit of `~/utils/onboarding` can show. + * + * The decisions are all in that module and tested there. What is left in + * the page is *when it asks*, *what it sends*, and *which of four empty + * states it draws* — and every one of those is a property that a passing + * build cannot demonstrate, because each of them looks identical on screen + * to the state it must not be confused with. + * + * Four failures are worth a build breaking over: + * + * - an empty channel picker drawn for a guild nothing has been mirrored + * for, which reads as a server with no voice channels and sends somebody + * hunting for a bug that is not there; + * - a superseded request drawn as a failure, which sends somebody to check + * a permission that was never tested; + * - a request that names a stored channel the mirror cannot resolve, which + * the applier refuses and which fails the whole intent; + * - a poll that outlives the page, which is `requeuePanel`'s old defect in + * another costume. + * + * The real locale files are loaded from disk, for the reason + * `adminQueuePage.spec.ts` loads them: a template asking for + * `admin.onboarding.headng.bad` renders the key at somebody, and nothing + * but a render catches it. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { + Suspense, + computed, + defineComponent, + h, + nextTick, + onBeforeUnmount, + onMounted, + ref, + useId, + watch, +} from 'vue' +import { createI18n, useI18n } from 'vue-i18n' + +import UiSelect from '../app/components/ui/UiSelect.vue' +import { useSay } from '../app/composables/useSay' +import OnboardingPage from '../app/pages/admin/onboarding.vue' + +/** The one datetime shape this page asks for. `i18n.config.ts` is a Nuxt + * macro and cannot be imported here, and an unregistered format renders + * as an empty string rather than as an error — which is exactly the kind + * of hole a page test exists to catch. */ +const UTC_MOMENT = { + utcMoment: { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + timeZoneName: 'short', + }, +} as const + +function load(locale: string) { + return JSON.parse(readFileSync(resolve(process.cwd(), `i18n/locales/${locale}.json`), 'utf8')) +} + +/** One guild, so the switcher has nothing to disambiguate and the page's + * own controls are the only ones on it. */ +const GUILDS = { guilds: [{ guild_id: '1', name: 'Alpha' }] } + +const INVITE = { + client_id: '42', + url: 'https://discord.com/oauth2/authorize?client_id=42&scope=bot', + permissions: '269487104', + scopes: ['bot', 'applications.commands'], +} + +const DIRECTORY = { + guild_id: '1', + synced_at: '2026-08-23T10:00:00+00:00', + channels: [ + { id: '10', name: 'Standup', kind: 'voice', position: 0 }, + { id: '11', name: 'Retro', kind: 'voice', position: 1 }, + ], + roles: [], + members: [{ discord_user_id: '99', display_name: 'Anna' }], +} + +/** Nothing has ever been mirrored: no channels, no roles, and `has_arrived` + * false to say which of the two that is. */ +const EMPTY_DIRECTORY = { guild_id: '1', synced_at: null, channels: [], roles: [], members: [] } + +function settings(stored: string | null) { + return { + guild_id: '1', + settings: [ + { + key: 'voice_channel_ids', + value: stored, + default: null, + required: false, + may_clear: true, + integer: false, + invalidates_consent: false, + takes_effect: 'next_reconcile', + deferred_while_recording: false, + }, + ], + } +} + +function setup(bot: { has_arrived: boolean }, request: Record | null = null) { + return { + guild_id: '1', + bot: { has_arrived: bot.has_arrived, seen_at: bot.has_arrived ? '2026-08-23T10:00:00Z' : null }, + request, + } +} + +const ASKED = { + id: '7', + status: 'pending', + requested_by: '99', + requested_at: '2026-08-23T10:05:00+00:00', + channel_ids: ['10'], + consent_role_name: 'Sturnus Consent', + settled_at: null, + error: null, +} + +/** + * `useAsyncData`, in as much detail as this page uses it. + * + * Written out rather than mocked to a fixed value, because two of the + * properties under test are about it: the page assigns straight into `data` + * when a 202 answers, and it presses `refresh()` from its own timer. + */ +function fakeUseAsyncData() { + return async ( + _key: string, + handler: () => Promise, + options?: { watch?: unknown[] }, + ) => { + const data = ref(null) + const error = ref(null) + const status = ref('idle') + + async function refresh() { + status.value = 'pending' + try { + data.value = await handler() + error.value = null + status.value = 'success' + } catch (thrown) { + error.value = thrown + status.value = 'error' + } + } + + if (options?.watch) watch(options.watch as never, () => void refresh()) + await refresh() + return { data, error, status, refresh } + } +} + +/** Nuxt auto-imports these; vitest runs without Nuxt. */ +function stubAutoImports(api: (path: string, options?: unknown) => Promise) { + vi.stubGlobal('ref', ref) + vi.stubGlobal('computed', computed) + vi.stubGlobal('watch', watch) + vi.stubGlobal('onMounted', onMounted) + vi.stubGlobal('onBeforeUnmount', onBeforeUnmount) + vi.stubGlobal('nextTick', nextTick) + vi.stubGlobal('useId', useId) + vi.stubGlobal('useI18n', () => ({ + ...useI18n(), + locales: computed(() => [{ code: 'en', language: 'en-GB' }]), + })) + vi.stubGlobal('useSay', useSay) + vi.stubGlobal('useHead', () => {}) + vi.stubGlobal('useApi', () => api) + vi.stubGlobal('useAsyncData', fakeUseAsyncData()) + vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: '/api' } })) + // The signed-in administrator, so "asked by you" has something to be + // true of. + vi.stubGlobal('useSession', () => ref({ discord_user_id: '99', is_admin: true })) +} + +/** An API serving one fixed world, with each route answered from a + * function so a test can change what a later poll finds. */ +function serving(world: { + setup: () => unknown + directory?: () => unknown + stored?: string | null + invite?: unknown +}) { + return vi.fn((path: string, options?: { method?: string; body?: unknown }) => { + if (path === '/guilds') return Promise.resolve(GUILDS) + if (path === '/invite') return Promise.resolve(world.invite ?? INVITE) + if (path === '/guilds/1/directory') { + return Promise.resolve(world.directory ? world.directory() : DIRECTORY) + } + if (path === '/guilds/1/settings') { + return Promise.resolve(settings(world.stored ?? null)) + } + if (path === '/guilds/1/setup') { + if (options?.method === 'POST') return Promise.resolve(world.setup()) + return Promise.resolve(world.setup()) + } + return Promise.reject(new Error(`unexpected ${path}`)) + }) +} + +/** The page awaits its data in `setup`, which Vue only runs inside a + * ``. Nuxt provides one around every page; here it is written + * out, in a render function rather than a template because vitest resolves + * `vue` to the build without a runtime compiler. */ +const Host = defineComponent({ + setup: () => () => h(Suspense, null, { default: () => h(OnboardingPage) }), +}) + +async function openPage(api: ReturnType) { + stubAutoImports(api as never) + const i18n = createI18n({ + legacy: false, + locale: 'en', + fallbackLocale: 'en', + messages: { en: load('en'), de: load('de') }, + // The one datetime shape this page asks for. `i18n.config.ts` is a + // Nuxt macro and cannot be imported here, and an unregistered format + // renders as an empty string rather than as an error -- which is + // exactly the kind of hole a page test exists to catch. + // Registered under the tag as well as the code, exactly as + // `i18n.config.ts` does: `useSay` formats with `en-GB`, because the + // tag and the code disagree about the order of a date. + datetimeFormats: { en: UTC_MOMENT, 'en-GB': UTC_MOMENT }, + }) + const page = mount(Host, { + global: { + plugins: [i18n], + components: { UiSelect }, + stubs: { NuxtLink: { template: '' } }, + }, + }) + await flushPromises() + await flushPromises() + return page +} + +/** + * The page's own submit button. + * + * Found by what it says rather than by position: the guild switcher is a + * `button` too, and it is the first one on the page — a test that clicked + * index zero would open a dropdown and assert about a request nobody made. + */ +function submitButton(page: ReturnType) { + return page + .findAll('button') + .find((button) => button.text().startsWith('Ask the bot') || button.text() === 'Asking…')! +} + +/** The body of the last POST this page made. */ +function posted(api: ReturnType): Record { + const call = [...api.mock.calls] + .reverse() + .find(([, options]) => (options as { method?: string } | undefined)?.method === 'POST') + return (call?.[1] as { body: Record }).body +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('a server the bot has not reached', () => { + it('says nobody has looked yet, rather than that it has no voice channels', async () => { + // The two states are one empty list on the wire and opposite + // instructions on screen. Getting this wrong sends somebody into + // Discord to create a channel that is already there. + const page = await openPage( + serving({ setup: () => setup({ has_arrived: false }), directory: () => EMPTY_DIRECTORY }), + ) + + expect(page.text()).toContain('Nobody has looked at this server yet') + expect(page.text()).not.toContain('This server has no voice channels') + // And no picker: an empty group of checkboxes reads as a list that + // failed to load. + expect(page.find('input[type="checkbox"]').exists()).toBe(false) + }) + + it('says the server has none once something has been mirrored', async () => { + const page = await openPage( + serving({ setup: () => setup({ has_arrived: true }), directory: () => EMPTY_DIRECTORY }), + ) + + expect(page.text()).toContain('This server has no voice channels') + expect(page.text()).not.toContain('Nobody has looked at this server yet') + }) + + it('fills the picker in on its own when the bot arrives', async () => { + // What the polling is for. Without it somebody watches an unchanging + // page and has no way to know when pressing Refresh would help. + let arrived = false + const api = serving({ + setup: () => setup({ has_arrived: arrived }), + directory: () => (arrived ? DIRECTORY : EMPTY_DIRECTORY), + }) + const page = await openPage(api) + expect(page.text()).toContain('Nobody has looked at this server yet') + + arrived = true + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + await flushPromises() + + expect(page.text()).toContain('Standup') + expect(page.text()).not.toContain('Nobody has looked at this server yet') + }) +}) + +describe('what a request names', () => { + it('sends the channels ticked', async () => { + const api = serving({ setup: () => setup({ has_arrived: true }) }) + const page = await openPage(api) + + const boxes = page.findAll('input[type="checkbox"]') + expect(boxes).toHaveLength(2) + await boxes[1]!.setValue(true) + await submitButton(page).trigger('click') + await flushPromises() + + // Every id a string. A snowflake through a JavaScript number comes + // back ending in other digits. + expect(posted(api)).toEqual({ channel_ids: ['11'], consent_role_name: null }) + }) + + it('carries what the server already records, and never a stale stored id', async () => { + // The failure this prevents is total rather than partial: the applier + // refuses a channel it cannot see, one refusal fails the whole intent, + // and a guild with one deleted channel in `voice_channel_ids` would + // have every request it ever made fail over a room nobody asked about. + const api = serving({ setup: () => setup({ has_arrived: true }), stored: '10,404' }) + const page = await openPage(api) + + await submitButton(page).trigger('click') + await flushPromises() + + expect(posted(api)).toEqual({ channel_ids: ['10'], consent_role_name: null }) + expect(page.text()).toContain('missing from the mirror') + }) + + it('does not offer to untick a channel this server already records', async () => { + // Setting up adds to `voice_channel_ids` and never removes from it, so + // that control would do nothing at all. + const api = serving({ setup: () => setup({ has_arrived: true }), stored: '10' }) + const page = await openPage(api) + + const boxes = page.findAll('input[type="checkbox"]') + expect((boxes[0]!.element as HTMLInputElement).checked).toBe(true) + expect((boxes[0]!.element as HTMLInputElement).disabled).toBe(true) + // Said in a word as well: a state carried only by a rendering style is + // a state nobody has been told. + expect(page.text()).toContain('already recorded') + }) + + it('refuses to ask for nothing, and says why beside the button', async () => { + const api = serving({ setup: () => setup({ has_arrived: true }) }) + const page = await openPage(api) + + expect((submitButton(page).element as HTMLButtonElement).disabled).toBe(true) + expect(page.text()).toContain('Tick at least one channel') + }) +}) + +describe('what came of it', () => { + it('never draws a superseded request as a failure', async () => { + // Nothing went wrong to a superseded request: it was replaced before + // the bot reached it and never attempted. + const page = await openPage( + serving({ + setup: () => setup({ has_arrived: true }, { ...ASKED, status: 'superseded' }), + }), + ) + + expect(page.text()).toContain('This request was replaced before anything was done to it') + expect(page.text()).toContain('Nothing went wrong') + expect(page.text()).not.toContain('The bot could not finish') + }) + + it('renders what the bot itself wrote, and says there is no retry', async () => { + const message + = 'I am missing the Manage Roles permission, so I could not create the `Sturnus Consent` role.' + const page = await openPage( + serving({ + setup: () => + setup({ has_arrived: true }, { ...ASKED, status: 'failed', error: message }), + }), + ) + + expect(page.text()).toContain(message) + expect(page.text()).toContain('nothing is retrying behind it') + expect(page.text()).toContain('that is a new request') + }) + + it('says the bot is not there when a request is pending in an empty mirror', async () => { + const page = await openPage( + serving({ + setup: () => setup({ has_arrived: false }, ASKED), + directory: () => EMPTY_DIRECTORY, + }), + ) + + expect(page.text()).toContain('Nothing is going to attempt this yet') + expect(page.text()).toContain('never attempted at all') + }) + + it('says when a colleague has replaced the request this browser sent', async () => { + // The supersede rule as it is actually experienced: `GET` answers with + // the guild's newest, which after somebody else asked is theirs. + let current: Record = ASKED + const api = serving({ setup: () => setup({ has_arrived: true }, current) }) + const page = await openPage(api) + + await page.findAll('input[type="checkbox"]')[0]!.setValue(true) + await submitButton(page).trigger('click') + await flushPromises() + expect(page.text()).not.toContain('This is not the request submitted from this browser') + + current = { ...ASKED, id: '8', requested_by: '55', status: 'applied', settled_at: '2026-08-23T10:06:00Z' } + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(page.text()).toContain('This is not the request submitted from this browser') + // And the outcome is still the guild's honest answer. + expect(page.text()).toContain('This server is set up') + }) + + it('leaves the form live while a request is pending, and says what asking again does', async () => { + // The API deliberately accepts a second request over a first: refusing + // one would leave somebody who mistyped a channel waiting out a tick + // before they could correct it. + const api = serving({ setup: () => setup({ has_arrived: true }, ASKED), stored: '10' }) + const page = await openPage(api) + + expect((submitButton(page).element as HTMLButtonElement).disabled).toBe(false) + expect(page.text()).toContain('replaces it rather than queueing behind it') + }) +}) + +describe('watching', () => { + it('stops the moment the request settles', async () => { + let status = 'pending' + const api = serving({ setup: () => setup({ has_arrived: true }, { ...ASKED, status }) }) + await openPage(api) + + status = 'applied' + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + const afterSettling = api.mock.calls.length + + await vi.advanceTimersByTimeAsync(30_000) + await flushPromises() + + expect(api.mock.calls.length).toBe(afterSettling) + }) + + it('makes no further request after the page has gone', async () => { + // `clearTimeout` cannot stop a timer that has already fired, and the + // continuation after its `await` installs a fresh one. The same defect + // left twenty database reads a minute running for the life of a tab. + const api = serving({ setup: () => setup({ has_arrived: true }, ASKED) }) + const page = await openPage(api) + + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + const beforeUnmount = api.mock.calls.length + + page.unmount() + await vi.advanceTimersByTimeAsync(30_000) + await flushPromises() + + expect(api.mock.calls.length).toBe(beforeUnmount) + }) +}) + +describe('a deployment with no application id', () => { + it('says so, and hands over what the URL generator in Discord needs instead', async () => { + const page = await openPage( + serving({ + setup: () => setup({ has_arrived: true }), + invite: { client_id: null, url: null, permissions: '269487104', scopes: ['bot'] }, + }), + ) + + expect(page.text()).toContain('This deployment has no invitation link') + expect(page.text()).toContain('STURNUS_DISCORD_CLIENT_ID') + expect(page.text()).toContain('269487104') + // No dead link offered in place of a working one. + expect(page.findAll('a[target="_blank"]')).toHaveLength(0) + }) + + it('names the role position the invitation cannot ask for', async () => { + // No bitmask expresses "my role must sit above that one", so the + // invitation link cannot carry it and only prose can -- said where it + // can still be acted on, in the same visit to Discord. + const page = await openPage(serving({ setup: () => setup({ has_arrived: true }) })) + expect(page.text()).toContain('Server Settings → Roles') + }) +}) diff --git a/console/test/navigation.spec.ts b/console/test/navigation.spec.ts index 11e962d..eee43fb 100644 --- a/console/test/navigation.spec.ts +++ b/console/test/navigation.spec.ts @@ -174,7 +174,18 @@ describe('who is offered the Admin View', () => { // it became a page, and `document_target` is still the fallback for a // guild that configures nothing here. Two adjacent entries is what // stops them reading as rival settings. + // + // Server Setup is the one entry that breaks the frequency rule, and it + // is first. Everything below it is read by somebody who already has a + // working server; that page is read by somebody who has none, and is + // the prerequisite of every other entry here -- there is nothing to + // configure, no consent to look at, no queue and no report until a + // server has been set up. An entry needed exactly once, by the person + // who knows this console least, cannot be the fifth thing in a list. + // It sits beside Bot Settings for the same reason Destinations does: + // finishing setup lands you there. expect(ADMIN_VIEW.entries.map((e) => e.labelKey)).toEqual([ + 'nav.onboarding', 'nav.botSettings', 'nav.destinations', 'nav.consents', diff --git a/console/test/onboarding.spec.ts b/console/test/onboarding.spec.ts new file mode 100644 index 0000000..1e948f0 --- /dev/null +++ b/console/test/onboarding.spec.ts @@ -0,0 +1,434 @@ +/** + * The three things about setup intents that an interface has to get right. + * + * Every assertion here is one of the three properties `docs/operations.md` + * §6.2.14 spells out, or a consequence of one: + * + * - a failure is terminal, so the page has to offer another ask rather than + * a wait; + * - the newest ask wins, so a superseded request is not a failure and a + * request that is not the one this browser submitted has to say so; + * - `pending` past a tick means the bot is not there, so an empty channel + * list is two opposite instructions depending on `bot.has_arrived`. + * + * None of them can be seen in a rendered page: every one of them looks + * identical on screen to the state it must not be confused with, which is + * exactly why they are decided in a module rather than in a template. + */ +import { describe, expect, it } from 'vitest' + +import { ApiError } from '../app/utils/apiError' +import { + APPLIED, + FAILED, + MAX_ROLE_NAME, + PENDING, + POLL_LIMIT, + SUPERSEDED, + describeSetupError, + draftProblem, + isSettled, + parseInvite, + parseSetupState, + pickerState, + reportRequest, + requestBody, + requestTone, + requesterLabel, + resubmitNote, + shouldPoll, + setupPath, + storedChannels, + submittedChannels, +} from '../app/utils/onboarding' + +/** What `GET /api/guilds/{id}/setup` answers for a guild nobody has asked + * about, on a server the bot has swept. */ +const ARRIVED = { + guild_id: '1', + bot: { has_arrived: true, seen_at: '2026-08-23T10:00:00+00:00' }, + request: null, +} + +function withRequest(request: Record) { + return { ...ARRIVED, request: { id: '7', requested_by: '99', ...request } } +} + +const CHANNELS = [ + { id: '10', name: 'Standup' }, + { id: '11', name: 'Retro' }, +] + +describe('reading the payload', () => { + it('keeps every snowflake a string', () => { + // The one defect that cannot be seen: `9134756382910273645` parsed as a + // number is `9134756382910274000`, which looks like an id and names + // nothing. Numbers are accepted from the wire and stringified rather + // than refused, because refusing would blank a page over a field an + // older API sent the other way. + const state = parseSetupState({ + guild_id: '9134756382910273645', + bot: { has_arrived: true, seen_at: null }, + request: { id: 7, requested_by: '99', channel_ids: ['10', '11'] }, + }) + expect(state.guildId).toBe('9134756382910273645') + expect(state.request?.id).toBe('7') + expect(state.request?.channelIds).toEqual(['10', '11']) + }) + + it('treats a payload that does not mention the bot as one that has not arrived', () => { + // Silence reads as "not known to have arrived", which makes the page + // wait. The other reading would have it announce that a server nobody + // has looked at has no voice channels. + expect(parseSetupState({}).botHasArrived).toBe(false) + expect(parseSetupState(null).botHasArrived).toBe(false) + }) + + it('reads a request with no status as still pending', () => { + expect(parseSetupState(withRequest({})).request?.status).toBe(PENDING) + }) + + it('drops a request with no id, which is not a request', () => { + expect(parseSetupState({ ...ARRIVED, request: {} }).request).toBeNull() + }) + + it('reads an invite that this deployment cannot build', () => { + // `url: null` is a deployment without `STURNUS_DISCORD_CLIENT_ID`, and + // the permissions still arrive: they are what somebody ticks in + // Discord's own URL generator instead. + const invite = parseInvite({ + client_id: null, + url: null, + permissions: '269487104', + scopes: ['bot', 'applications.commands'], + }) + expect(invite.url).toBeNull() + expect(invite.permissions).toBe('269487104') + expect(invite.scopes).toEqual(['bot', 'applications.commands']) + }) +}) + +describe('what an empty channel list is', () => { + it('is "nobody has looked yet" while nothing has been mirrored', () => { + expect(pickerState({ botHasArrived: false, directoryFailed: false, channelCount: 0 })).toBe( + 'waiting', + ) + }) + + it('is "this server has no voice channels" once something has', () => { + // The whole distinction. These two calls differ in one boolean and the + // instructions they produce are opposites: wait ten seconds, or go and + // create a channel in Discord. + expect(pickerState({ botHasArrived: true, directoryFailed: false, channelCount: 0 })).toBe( + 'empty', + ) + }) + + it('is still "nobody has looked yet" when the directory call also failed', () => { + // A directory that answered nothing for a guild nothing has been + // mirrored for answered correctly. Reporting it as broken would send + // somebody to check an API that is working. + expect(pickerState({ botHasArrived: false, directoryFailed: true, channelCount: 0 })).toBe( + 'waiting', + ) + }) + + it('says so when the names could not be read at all', () => { + expect(pickerState({ botHasArrived: true, directoryFailed: true, channelCount: 0 })).toBe( + 'unreadable', + ) + }) + + it('offers the picker when there is something to pick', () => { + expect(pickerState({ botHasArrived: true, directoryFailed: false, channelCount: 2 })).toBe( + 'ready', + ) + }) +}) + +describe('the channels a guild already records', () => { + it('separates the ones the mirror can name from the ones it cannot', () => { + expect(storedChannels('10,404', CHANNELS)).toEqual({ recorded: ['10'], stale: ['404'] }) + }) + + it('never carries a stale stored id into a request', () => { + // The failure this prevents is total rather than partial: the applier + // refuses a channel a request names that it cannot see, a refusal is a + // problem, and one problem settles the whole intent as `failed`. A + // guild with one deleted channel in `voice_channel_ids` would have + // every setup request it ever made fail over a room nobody asked about. + const stored = storedChannels('10,404', CHANNELS) + expect(submittedChannels(stored, ['11'])).toEqual(['10', '11']) + }) + + it('holds an empty list for a guild that records nothing yet', () => { + expect(storedChannels(null, CHANNELS)).toEqual({ recorded: [], stale: [] }) + }) + + it('does not name a channel twice when an already-recorded one is ticked', () => { + const stored = storedChannels('10', CHANNELS) + expect(submittedChannels(stored, ['10', '11'])).toEqual(['10', '11']) + }) +}) + +describe('what the form will not send', () => { + it('refuses a request that names no channel, which the API refuses too', () => { + expect(draftProblem({ channelIds: [], consentRoleName: '' })?.key).toBe( + 'admin.onboarding.needChannel', + ) + }) + + it('refuses a role name Discord would refuse, while somebody is still typing', () => { + const problem = draftProblem({ + channelIds: ['10'], + consentRoleName: 'x'.repeat(MAX_ROLE_NAME + 1), + }) + expect(problem?.key).toBe('admin.onboarding.roleTooLong') + }) + + it('accepts a name of exactly the limit', () => { + expect( + draftProblem({ channelIds: ['10'], consentRoleName: 'x'.repeat(MAX_ROLE_NAME) }), + ).toBeNull() + }) + + it('accepts a blank name, which is how a guild keeps the role it has', () => { + expect(draftProblem({ channelIds: ['10'], consentRoleName: ' ' })).toBeNull() + }) + + it('sends a blank name as null rather than as an empty string', () => { + // Absent and null both mean "do not name one". An empty string is + // refused by the API outright, and omitting something must never be + // the destructive path. + expect(requestBody({ channelIds: ['10'], consentRoleName: ' ' })).toEqual({ + channel_ids: ['10'], + consent_role_name: null, + }) + }) + + it('trims a name somebody pasted with a space on the end', () => { + expect(requestBody({ channelIds: ['10'], consentRoleName: ' Consent ' })).toEqual({ + channel_ids: ['10'], + consent_role_name: 'Consent', + }) + }) +}) + +describe('what a status means', () => { + it('reads a pending row on a server the bot has swept as waiting', () => { + expect(requestTone(PENDING, true)).toBe('waiting') + }) + + it('reads a pending row on a server the bot is not in as stalled', () => { + // `pending` past a tick is not slowness. The guild has no gateway + // object to iterate, so the row will never be attempted at all. + expect(requestTone(PENDING, false)).toBe('stalled') + }) + + it('never reads a superseded row as a failure', () => { + // The single most consequential line in this file. Nothing went wrong + // to a superseded request: it was replaced before the bot reached it + // and never acted on, and drawing it in the failure colour would send + // somebody to check a permission that was never tested. + expect(requestTone(SUPERSEDED, true)).toBe('neutral') + expect(requestTone(FAILED, true)).toBe('bad') + expect(requestTone(APPLIED, true)).toBe('good') + }) + + it('renders an outcome written by a newer bot rather than guessing at it', () => { + // `outcome` is text and not a database enum precisely so that a word + // this build has never seen is a row a reader can ignore. A console + // that narrowed it back to four would hand that property back. + expect(requestTone('quarantined', true)).toBe('unknown') + expect(isSettled('quarantined')).toBe(true) + expect(isSettled(PENDING)).toBe(false) + }) +}) + +describe('what the panel says', () => { + const context = { viewer: '99', submitted: null } + + it('says nothing at all when nobody has ever asked', () => { + expect(reportRequest(parseSetupState(ARRIVED), context)).toBeNull() + }) + + it('carries what the bot itself wrote on a failure and on nothing else', () => { + const failed = reportRequest( + parseSetupState(withRequest({ status: FAILED, error: 'I am missing Manage Roles' })), + context, + ) + expect(failed?.error).toBe('I am missing Manage Roles') + + // An `error` beside any other outcome is a row written by hand, and + // rendering it would make an applied setup look like a broken one. + const applied = reportRequest( + parseSetupState(withRequest({ status: APPLIED, error: 'left over' })), + context, + ) + expect(applied?.error).toBeNull() + }) + + it('says a failure is the end of that request, not a wait', () => { + const report = reportRequest(parseSetupState(withRequest({ status: FAILED })), context) + expect(report?.notes.map((note) => note.key)).toEqual([ + 'admin.onboarding.failedTerminal', + 'admin.onboarding.roleOrder', + ]) + }) + + it('says a superseded request was replaced, not that it went wrong', () => { + const report = reportRequest(parseSetupState(withRequest({ status: SUPERSEDED })), context) + expect(report?.tone).toBe('neutral') + expect(report?.notes.map((note) => note.key)).toEqual(['admin.onboarding.supersededNote']) + expect(report?.badge.key).toBe('admin.onboarding.status.neutral') + }) + + it('says the bot is not there when a pending row is sitting in an empty mirror', () => { + const report = reportRequest( + parseSetupState({ ...withRequest({}), bot: { has_arrived: false, seen_at: null } }), + context, + ) + expect(report?.tone).toBe('stalled') + expect(report?.notes.map((note) => note.key)).toEqual(['admin.onboarding.stalledNote']) + }) + + it('names the outcome it has never heard of rather than hiding it', () => { + const report = reportRequest(parseSetupState(withRequest({ status: 'quarantined' })), context) + expect(report?.badge).toEqual({ + key: 'admin.onboarding.status.unknown', + params: { status: 'quarantined' }, + }) + }) + + it('says when the request on screen is not the one this browser submitted', () => { + // The supersede rule as it is actually experienced. A colleague pressed + // the button thirty seconds later; `GET` answers with theirs, because + // theirs is what the guild will be configured from. Everything below + // then describes a request this reader did not make, and reading it as + // their own is how somebody concludes their channel list was applied. + const report = reportRequest(parseSetupState(withRequest({ id: '8', status: APPLIED })), { + viewer: '99', + submitted: '7', + }) + expect(report?.notes[0]?.key).toBe('admin.onboarding.replacedYours') + // The outcome is still the guild's honest answer and stays as it is. + expect(report?.tone).toBe('good') + }) + + it('says nothing about replacement when the request is the one submitted', () => { + const report = reportRequest(parseSetupState(withRequest({ status: APPLIED })), { + viewer: '99', + submitted: '7', + }) + expect(report?.notes[0]?.key).toBe('admin.onboarding.appliedNote') + }) +}) + +describe('who asked', () => { + const members = [{ id: '99', name: 'Anna' }] + + it('says "you" where it was this reader', () => { + const request = parseSetupState(withRequest({})).request! + expect(requesterLabel(request, '99', members).key).toBe('admin.onboarding.askedByYou') + }) + + it('names the other administrator where the mirror knows them', () => { + const request = parseSetupState(withRequest({})).request! + expect(requesterLabel(request, '1', members)).toEqual({ + key: 'admin.onboarding.askedBy', + params: { who: 'Anna' }, + }) + }) + + it('falls back to the bare id, which is what this console does everywhere', () => { + // `guild_member` holds the consent role's and the admin role's members + // and nobody else, so an administrator outside both is a legitimate + // miss rather than a fault. + const request = parseSetupState(withRequest({ requested_by: '404' })).request! + expect(requesterLabel(request, '1', members)).toEqual({ + key: 'admin.onboarding.askedByUnresolved', + params: { id: '404' }, + }) + }) +}) + +describe('what pressing the button again does', () => { + it('warns that a second ask replaces a pending one rather than queueing', () => { + expect(resubmitNote(parseSetupState(withRequest({})))?.key).toBe( + 'admin.onboarding.resubmitReplaces', + ) + }) + + it('invites another ask after a failure, which is the only way forward', () => { + expect(resubmitNote(parseSetupState(withRequest({ status: FAILED })))?.key).toBe( + 'admin.onboarding.resubmitAfterFailure', + ) + }) + + it('says nothing before anybody has asked at all', () => { + expect(resubmitNote(parseSetupState(ARRIVED))).toBeNull() + }) +}) + +describe('how long the page keeps watching', () => { + it('watches a pending request', () => { + expect(shouldPoll(parseSetupState(withRequest({})), 0)).toBe(true) + }) + + it('watches a guild the bot has not reached even with nothing asked', () => { + // This is what makes the page come alive on its own the moment the bot + // joins, rather than leaving somebody to guess when to press Refresh. + const state = parseSetupState({ ...ARRIVED, bot: { has_arrived: false, seen_at: null } }) + expect(shouldPoll(state, 0)).toBe(true) + }) + + it('stops once the request has settled', () => { + expect(shouldPoll(parseSetupState(withRequest({ status: APPLIED })), 0)).toBe(false) + expect(shouldPoll(parseSetupState(withRequest({ status: SUPERSEDED })), 0)).toBe(false) + }) + + it('gives up rather than polling a forgotten tab for the life of the browser', () => { + // The wait this bounds is a human one -- somebody has to open Discord + // and add the bot -- and not the ten-second tick. + const state = parseSetupState({ ...ARRIVED, bot: { has_arrived: false, seen_at: null } }) + expect(shouldPoll(state, POLL_LIMIT)).toBe(false) + }) + + it('has nothing to watch before the first answer arrives', () => { + expect(shouldPoll(null, 0)).toBe(false) + }) +}) + +describe('where the requests go', () => { + it('addresses a guild by the string its id is', () => { + expect(setupPath('9134756382910273645')).toBe('/guilds/9134756382910273645/setup') + }) +}) + +describe('why a call did not work', () => { + it('tells "the API said no" from "the API could not be reached"', () => { + expect(describeSetupError(new ApiError('/setup', { status: 0 })).key).toBe( + 'admin.onboarding.errorUnreachable', + ) + expect(describeSetupError(new ApiError('/setup', { status: 400 })).key).toBe( + 'admin.onboarding.errorRefused', + ) + expect(describeSetupError(new ApiError('/setup', { status: 404 })).key).toBe( + 'admin.onboarding.errorGone', + ) + expect(describeSetupError(new ApiError('/setup', { status: 401 })).key).toBe( + 'admin.onboarding.errorSession', + ) + }) + + it('names a status it has no sentence for', () => { + expect(describeSetupError(new ApiError('/setup', { status: 503 }))).toEqual({ + key: 'admin.onboarding.errorStatus', + params: { status: '503' }, + }) + }) + + it('treats something that is not an API error at all as unreachable', () => { + expect(describeSetupError(new Error('boom')).key).toBe('admin.onboarding.errorUnreachable') + }) +}) diff --git a/docs/operations.md b/docs/operations.md index f96a036..f2e17de 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2351,6 +2351,11 @@ same planner `/setup` uses and writes back what happened. | `POST /api/guilds/{id}/setup` | Asks: `{"channel_ids": ["…"], "consent_role_name": "…"}`. Answers 202 with the guild's setup state | | `GET /api/guilds/{id}/setup` | The same state. The console polls it until `request.status` stops being `pending` | +The page that drives all three is **Server Setup** (`/admin/onboarding`) in +the console, first entry of the Admin View. It walks the four steps in +order — the invitation, the server, what to record, and what came of it — +and everything below is what it has to express rather than merely show. + Three things an operator should know about the behaviour. **A failure is terminal.** The tick runs six times a minute forever, so an