Add auth e2e regression pack with fixtures and CI stack
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 1m41s
CI / Lint, typecheck, test (push) Failing after 56s
CI / Auth e2e pack (push) Failing after 43s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Has been cancelled
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 1m41s
CI / Lint, typecheck, test (push) Failing after 56s
CI / Auth e2e pack (push) Failing after 43s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Has been cancelled
The seed script now provisions the documented fixture matrix (fixture-admin / fixture-user / fixture-pending, idempotent upserts, rate-limit reset for disposable databases). A six-test Playwright pack drives the real UI against a full local stack with Mailpit: complete signup→mail→verify→first-login journey, wrong-password error, guarded route redirect honoring ?next (race between the login page and the anonymous guard fixed by teaching the guard about ?next), menu logout, site-admin gating, and a profile rename reflected in the top bar. The pack self-skips without E2E_MAILPIT_URL, so the CD smoke stage (now pinned to smoke.spec.ts) stays untouched; a new CI job boots api + web dev server against postgres/mailpit service containers and runs the pack on every PR and push. Also fixed: the web api client choked on empty 201 bodies. e2e/README.md documents targets and fixtures. Closes #20 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0bc80c9f93
commit
1cea675983
@ -91,7 +91,7 @@ jobs:
|
|||||||
echo "Test stage did not become ready" >&2; exit 1
|
echo "Test stage did not become ready" >&2; exit 1
|
||||||
|
|
||||||
- name: Run smoke suite
|
- name: Run smoke suite
|
||||||
run: E2E_BASE_URL=https://test.dorfteich.cloud pnpm --filter @dorfteich/web exec playwright test
|
run: E2E_BASE_URL=https://test.dorfteich.cloud pnpm --filter @dorfteich/web exec playwright test e2e/smoke.spec.ts
|
||||||
|
|
||||||
promote-int:
|
promote-int:
|
||||||
name: Promote to Int
|
name: Promote to Int
|
||||||
|
|||||||
@ -59,6 +59,63 @@ jobs:
|
|||||||
- name: Translation key parity (de/en)
|
- name: Translation key parity (de/en)
|
||||||
run: pnpm i18n:check
|
run: pnpm i18n:check
|
||||||
|
|
||||||
|
auth-e2e:
|
||||||
|
name: Auth e2e pack
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17.5-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: e2e
|
||||||
|
POSTGRES_PASSWORD: e2e
|
||||||
|
POSTGRES_DB: e2e
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://e2e:e2e@postgres:5432/e2e
|
||||||
|
APP_BASE_URL: http://localhost:5173
|
||||||
|
SMTP_HOST: mailpit
|
||||||
|
SMTP_PORT: '1025'
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Seed fixtures
|
||||||
|
run: pnpm --filter @dorfteich/api db:seed
|
||||||
|
|
||||||
|
- name: Start api and web dev server
|
||||||
|
run: |
|
||||||
|
(cd apps/api && PORT=3001 node dist/main.js &)
|
||||||
|
(pnpm --filter @dorfteich/web dev -- --port 5173 --strictPort &)
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -sf http://localhost:5173/ >/dev/null
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: pnpm --filter @dorfteich/web exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run auth pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts
|
||||||
|
|
||||||
images:
|
images:
|
||||||
name: Build container images
|
name: Build container images
|
||||||
# PR-only: on main the CD workflow builds and pushes the same images —
|
# PR-only: on main the CD workflow builds and pushes the same images —
|
||||||
|
|||||||
@ -1,19 +1,84 @@
|
|||||||
/**
|
/**
|
||||||
* Development/Test fixture seeding. Idempotent: running it twice must not
|
* Development/Test fixture seeding. Idempotent: running it twice must not
|
||||||
* duplicate anything. Real fixtures (users, ponds, pages) arrive with their
|
* duplicate anything. Never run against production data.
|
||||||
* feature stories (#20, #32); until then this only proves the wiring.
|
*
|
||||||
|
* Fixture matrix (documented in apps/web/e2e/README.md):
|
||||||
|
* fixture-admin active, Site Admin
|
||||||
|
* fixture-user active, regular account
|
||||||
|
* fixture-pending registered but e-mail not verified
|
||||||
|
*
|
||||||
|
* All fixture accounts share the password below — they exist only on
|
||||||
|
* dev machines and disposable CI/Test databases.
|
||||||
*/
|
*/
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, UserStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { hashPassword } from '../src/users/password';
|
||||||
|
|
||||||
|
export const FIXTURE_PASSWORD = 'fixture passwort 123';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface FixtureUser {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
status: UserStatus;
|
||||||
|
isSiteAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIXTURES: FixtureUser[] = [
|
||||||
|
{ username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true },
|
||||||
|
{ username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false },
|
||||||
|
{
|
||||||
|
username: 'fixture-pending',
|
||||||
|
displayName: 'Fixture Pending',
|
||||||
|
status: 'PENDING_VERIFICATION',
|
||||||
|
isSiteAdmin: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
|
||||||
|
const email = `${fixture.username}@dorfteich.test`;
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { username: fixture.username },
|
||||||
|
create: {
|
||||||
|
username: fixture.username,
|
||||||
|
email,
|
||||||
|
displayName: fixture.displayName,
|
||||||
|
locale: 'de',
|
||||||
|
status: fixture.status,
|
||||||
|
isSiteAdmin: fixture.isSiteAdmin,
|
||||||
|
emailVerifiedAt: fixture.status === 'ACTIVE' ? new Date() : null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: fixture.status,
|
||||||
|
isSiteAdmin: fixture.isSiteAdmin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.userIdentity.upsert({
|
||||||
|
where: { provider_subject: { provider: 'password', subject: user.id } },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
provider: 'password',
|
||||||
|
subject: user.id,
|
||||||
|
credential: await hashPassword(FIXTURE_PASSWORD),
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
// Fresh rate-limit budget for e2e runs — seed targets are always
|
||||||
|
// disposable dev/CI databases, never production.
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
for (const fixture of FIXTURES) {
|
||||||
|
await upsertFixtureUser(fixture);
|
||||||
|
}
|
||||||
await prisma.instanceSetting.upsert({
|
await prisma.instanceSetting.upsert({
|
||||||
where: { key: 'seed.marker' },
|
where: { key: 'seed.marker' },
|
||||||
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
||||||
update: { value: { seededAt: new Date().toISOString() } },
|
update: { value: { seededAt: new Date().toISOString() } },
|
||||||
});
|
});
|
||||||
console.log('seed: done (no fixtures defined yet)');
|
console.log(`seed: done (${FIXTURES.length} fixture users)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
46
apps/web/e2e/README.md
Normal file
46
apps/web/e2e/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# End-to-end tests
|
||||||
|
|
||||||
|
Two Playwright suites with different targets:
|
||||||
|
|
||||||
|
| Suite | Target | Where it runs |
|
||||||
|
| --------------- | --------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `smoke.spec.ts` | any deployed stage | CD pipeline against `https://test.dorfteich.cloud` after every deploy |
|
||||||
|
| `auth.spec.ts` | full local stack **with Mailpit** | CI job `auth-e2e` on every PR/push; locally against the dev stack |
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Stack: database + Mailpit, api (3001), web dev server (5173)
|
||||||
|
docker compose -f deploy/compose/docker-compose.yml -f deploy/compose/compose.dev.yml up -d db mailpit
|
||||||
|
DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich pnpm --filter @dorfteich/api db:seed
|
||||||
|
DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich PORT=3001 pnpm --filter @dorfteich/api start:dev &
|
||||||
|
pnpm --filter @dorfteich/web dev &
|
||||||
|
|
||||||
|
# 2. Tests
|
||||||
|
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://localhost:8025 pnpm --filter @dorfteich/web e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
`auth.spec.ts` skips itself when `E2E_MAILPIT_URL` is unset, so the CD
|
||||||
|
smoke run never trips over it.
|
||||||
|
|
||||||
|
## Fixture matrix
|
||||||
|
|
||||||
|
Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running
|
||||||
|
never duplicates). Shared password: `fixture passwort 123`. Fixtures exist
|
||||||
|
only on dev machines and disposable CI/Test databases.
|
||||||
|
|
||||||
|
| Username | State | Purpose |
|
||||||
|
| ----------------- | ------------------- | ------------------------------------ |
|
||||||
|
| `fixture-admin` | active, Site Admin | admin UI/permissions cases |
|
||||||
|
| `fixture-user` | active | regular journeys, settings, sessions |
|
||||||
|
| `fixture-pending` | e-mail not verified | unverified-login cases |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- New feature packs get their own `<feature>.spec.ts` next to these and
|
||||||
|
extend the fixture matrix here (permission matrix arrives with M5,
|
||||||
|
issue #60).
|
||||||
|
- Use `contextForUser()` from `helpers.ts` for signed-in tests — it logs
|
||||||
|
in through the api and hands you a browser context with the session
|
||||||
|
cookie, no UI login repetition.
|
||||||
|
- Flaky tests are defects (ADR 0014): fix or quarantine immediately.
|
||||||
107
apps/web/e2e/auth.spec.ts
Normal file
107
apps/web/e2e/auth.spec.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser, latestMailFor, tokenFromMail } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M1 auth regression pack. Runs against a full local stack with Mailpit
|
||||||
|
* (CI job `auth-e2e`, or `deploy/compose` dev stack locally); skipped
|
||||||
|
* where no mail catcher is available (e.g. the CD smoke run against a
|
||||||
|
* deployed stage — that pipeline runs e2e/smoke.spec.ts only anyway).
|
||||||
|
*/
|
||||||
|
const MAILPIT_URL = process.env.E2E_MAILPIT_URL;
|
||||||
|
test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)');
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
test('full signup journey: register, mail, verify, first login', async ({ page }) => {
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const username = `e2e-${stamp}`;
|
||||||
|
const email = `${username}@dorfteich.test`;
|
||||||
|
const password = 'ein sehr langes e2e passwort';
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel(/username|benutzername/i).fill(username);
|
||||||
|
await page.getByLabel(/e-mail/i).fill(email);
|
||||||
|
await page.getByLabel(/display name|anzeigename/i).fill(username);
|
||||||
|
await page.getByLabel(/^password|^passwort/i).fill(password);
|
||||||
|
await page.getByRole('button', { name: /register|registrieren/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible();
|
||||||
|
|
||||||
|
const mail = await latestMailFor(MAILPIT_URL!, email);
|
||||||
|
const token = tokenFromMail(mail.text);
|
||||||
|
|
||||||
|
await page.goto(`/verify-email?token=${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: /sign-in|anmeldung/i }).click();
|
||||||
|
await page.getByLabel(/username or e-mail|benutzername oder e-mail/i).fill(username);
|
||||||
|
await page.getByLabel(/^password|^passwort/i).fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|anmelden/i }).click();
|
||||||
|
|
||||||
|
// Signed in: the user menu shows the display name.
|
||||||
|
await expect(page.getByRole('button', { name: username })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login rejects a wrong password with a visible error', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/username or e-mail|benutzername oder e-mail/i).fill('fixture-user');
|
||||||
|
await page.getByLabel(/^password|^passwort/i).fill('definitiv falsch');
|
||||||
|
await page.getByRole('button', { name: /sign in|anmelden/i }).click();
|
||||||
|
await expect(page.getByRole('alert')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anonymous visitors are redirected to login and return after', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await expect(page).toHaveURL(/\/login\?next=%2Fsettings/);
|
||||||
|
await page.getByLabel(/username or e-mail|benutzername oder e-mail/i).fill('fixture-user');
|
||||||
|
await page.getByLabel(/^password|^passwort/i).fill('fixture passwort 123');
|
||||||
|
await page.getByRole('button', { name: /sign in|anmelden/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/settings/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixture user signs out via the menu', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'Fixture User' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /sign out|abmelden/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin menu and page are reserved for site admins', async ({ browser }) => {
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
const adminPage = await admin.newPage();
|
||||||
|
await adminPage.goto('/admin');
|
||||||
|
await expect(adminPage.getByRole('heading', { name: /administration/i })).toBeVisible();
|
||||||
|
await admin.close();
|
||||||
|
|
||||||
|
const member = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const memberPage = await member.newPage();
|
||||||
|
await memberPage.goto('/admin');
|
||||||
|
// Non-admins are bounced to the start page.
|
||||||
|
await expect(memberPage).toHaveURL(/\/$/);
|
||||||
|
await member.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile display-name change shows up in the top bar', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/settings');
|
||||||
|
const nameField = page.getByLabel(/display name|anzeigename/i);
|
||||||
|
await nameField.fill('Fixture Umbenannt');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^save$|^speichern$/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Fixture Umbenannt' })).toBeVisible();
|
||||||
|
// Restore for the next run (idempotent pack).
|
||||||
|
await nameField.fill('Fixture User');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^save$|^speichern$/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Fixture User' })).toBeVisible();
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
57
apps/web/e2e/helpers.ts
Normal file
57
apps/web/e2e/helpers.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { type Browser, type BrowserContext, request } from '@playwright/test';
|
||||||
|
|
||||||
|
export const FIXTURE_PASSWORD = 'fixture passwort 123';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs a fixture user in through the api and returns a browser context
|
||||||
|
* that already carries the session cookie — UI tests skip the login form
|
||||||
|
* unless the form itself is under test.
|
||||||
|
*/
|
||||||
|
export async function contextForUser(
|
||||||
|
browser: Browser,
|
||||||
|
baseURL: string,
|
||||||
|
username: string,
|
||||||
|
password: string = FIXTURE_PASSWORD,
|
||||||
|
): Promise<BrowserContext> {
|
||||||
|
const api = await request.newContext({ baseURL });
|
||||||
|
const response = await api.post('/api/v1/auth/login', {
|
||||||
|
data: { usernameOrEmail: username, password },
|
||||||
|
});
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`fixture login for ${username} failed: ${response.status()}`);
|
||||||
|
}
|
||||||
|
const storageState = await api.storageState();
|
||||||
|
await api.dispose();
|
||||||
|
return browser.newContext({ baseURL, storageState });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest mail for an address from the Mailpit REST api. */
|
||||||
|
export async function latestMailFor(
|
||||||
|
mailpitUrl: string,
|
||||||
|
address: string,
|
||||||
|
): Promise<{ subject: string; text: string }> {
|
||||||
|
const api = await request.newContext({ baseURL: mailpitUrl });
|
||||||
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
|
const list = await api.get('/api/v1/search', {
|
||||||
|
params: { query: `to:${address}`, limit: 1 },
|
||||||
|
});
|
||||||
|
const body = (await list.json()) as { messages?: { ID: string; Subject: string }[] };
|
||||||
|
const found = body.messages?.[0];
|
||||||
|
if (found) {
|
||||||
|
const message = await api.get(`/api/v1/message/${found.ID}`);
|
||||||
|
const details = (await message.json()) as { Text: string };
|
||||||
|
await api.dispose();
|
||||||
|
return { subject: found.Subject, text: details.Text };
|
||||||
|
}
|
||||||
|
// The outbox worker delivers every 15s — poll until it does.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
await api.dispose();
|
||||||
|
throw new Error(`no mail arrived for ${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenFromMail(text: string): string {
|
||||||
|
const match = text.match(/token=([A-Za-z0-9_-]+)/);
|
||||||
|
if (!match) throw new Error('mail contains no token link');
|
||||||
|
return match[1]!;
|
||||||
|
}
|
||||||
@ -17,8 +17,15 @@ export function RequireAuth(): React.JSX.Element {
|
|||||||
/** Wraps auth pages: signed-in users have no business on /login etc. */
|
/** Wraps auth pages: signed-in users have no business on /login etc. */
|
||||||
export function RequireAnonymous(): React.JSX.Element {
|
export function RequireAnonymous(): React.JSX.Element {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
if (isLoading) return <></>;
|
if (isLoading) return <></>;
|
||||||
if (user) return <Navigate to="/" replace />;
|
if (user) {
|
||||||
|
// Honor ?next= here: after a successful login the user state updates
|
||||||
|
// and this redirect fires before the page's own navigate() —
|
||||||
|
// both paths must agree on the target.
|
||||||
|
const next = new URLSearchParams(location.search).get('next');
|
||||||
|
return <Navigate to={next && next.startsWith('/') ? next : '/'} replace />;
|
||||||
|
}
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,8 +35,9 @@ async function requestJson<T>(method: string, path: string, body?: unknown): Pro
|
|||||||
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T;
|
// 201/204 responses may carry no body at all.
|
||||||
return (await response.json()) as T;
|
const text = await response.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
|
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
|
||||||
|
|||||||
4
apps/web/test-results/.last-run.json
Normal file
4
apps/web/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user