Files
netris-nestri/apps/api/test/team.test.ts
T
Wanjohi bc59f7842b feat(api): create a team; slugs the website serves as pages are reserved
POST /team makes another team with the caller as owner. The website now
serves a team at nestri.io/<slug>, so Team.Slug refuses its top-level
pages (home, new, pricing, ...) and a personal team whose generated slug
would be one gets a suffix instead.
2026-09-28 23:56:13 +03:00

158 lines
5.4 KiB
TypeScript

import { afterAll, describe, expect, test } from 'bun:test';
import { AccessToken } from '@nestri/core/access-token/index';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Identifier } from '@nestri/core/id';
import { Member } from '@nestri/core/team/member';
import { app } from '../app/index';
import './setup';
const sql = testDb();
const createdUserIds: string[] = [];
async function person(label: string) {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: owner.userId,
teamId: null,
name: label
});
return {
...owner,
headers: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' }
};
}
function rename(who: { headers: Record<string, string> }, teamId: string, body: unknown) {
return app.request(`/team/${teamId}`, {
method: 'PATCH',
headers: who.headers,
body: JSON.stringify(body)
});
}
/** Unique per run, so a slug from a previous run never collides. */
const slug = (s: string) => `${s}-${Identifier.ascending('team').slice(-8).toLowerCase()}`;
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
}
});
describe('GET /team', () => {
test('lists your personal team, with your role on it', async () => {
const me = await person('team-list');
const res = await app.request('/team', { headers: me.headers });
expect(res.status).toBe(200);
const { data } = (await res.json()) as any;
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ id: me.teamId, role: 'owner' });
});
});
describe('PATCH /team/:id', () => {
test('an owner renames name and slug', async () => {
const me = await person('team-rename');
const s = slug('renamed');
const res = await rename(me, me.teamId, { name: 'Renamed', slug: s });
expect(res.status).toBe(200);
expect(((await res.json()) as any).data).toMatchObject({ name: 'Renamed', slug: s });
});
test("someone else's team is a 404, not a 403", async () => {
const me = await person('team-probe');
const them = await person('team-probed');
const res = await rename(me, them.teamId, { name: 'Mine now' });
expect(res.status).toBe(404);
});
test('a plain member cannot rename', async () => {
const owner = await person('team-owner');
const member = await person('team-member');
await Member.create({
id: Identifier.ascending('teamMember'),
teamId: owner.teamId,
userId: member.userId,
role: 'member'
});
const res = await rename(member, owner.teamId, { name: 'Coup' });
expect(res.status).toBe(403);
});
test('a taken slug is a 409 on the slug', async () => {
const a = await person('team-slug-a');
const b = await person('team-slug-b');
const s = slug('taken');
expect((await rename(a, a.teamId, { slug: s })).status).toBe(200);
const res = await rename(b, b.teamId, { slug: s });
expect(res.status).toBe(409);
expect(((await res.json()) as any).param).toBe('slug');
});
test('a malformed slug is refused before it reaches the database', async () => {
const me = await person('team-bad-slug');
expect((await rename(me, me.teamId, { slug: 'Not A Slug' })).status).toBe(400);
expect((await rename(me, me.teamId, {})).status).toBe(400);
});
});
describe('GET /team/:id/members', () => {
test('names the people on your team, and nobody can list a team they are not on', async () => {
const me = await person('team-people');
const other = await person('team-people-other');
const res = await app.request(`/team/${me.teamId}/members`, { headers: me.headers });
expect(res.status).toBe(200);
const { data } = (await res.json()) as any;
expect(data).toEqual([
expect.objectContaining({ userId: me.userId, role: 'owner', name: 'team-people' })
]);
const refused = await app.request(`/team/${other.teamId}/members`, { headers: me.headers });
expect(refused.status).toBe(404);
});
});
describe('GET /box', () => {
test('a person with no boxes gets an empty list', async () => {
const me = await person('box-none');
const res = await app.request('/box', { headers: me.headers });
expect(res.status).toBe(200);
expect(((await res.json()) as any).data).toEqual([]);
});
});
describe('POST /team', () => {
test('creates a second team with you as owner, listed after your personal one', async () => {
const me = await person('team-create');
const s = slug('second');
const res = await app.request('/team', {
method: 'POST',
headers: me.headers,
body: JSON.stringify({ name: 'Second', slug: s })
});
expect(res.status).toBe(200);
expect(((await res.json()) as any).data).toMatchObject({
name: 'Second',
slug: s,
role: 'owner'
});
const list = ((await (await app.request('/team', { headers: me.headers })).json()) as any).data;
expect(list.map((t: any) => t.slug)).toEqual([expect.any(String), s]);
expect(list[0].id).toBe(me.teamId);
});
test('a taken slug is a 409, and a page of the site is a 400', async () => {
const me = await person('team-create-taken');
const s = slug('dupe');
const post = (body: unknown) =>
app.request('/team', { method: 'POST', headers: me.headers, body: JSON.stringify(body) });
expect((await post({ name: 'A', slug: s })).status).toBe(200);
expect((await post({ name: 'B', slug: s })).status).toBe(409);
expect((await post({ name: 'C', slug: 'pricing' })).status).toBe(400);
});
});