Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "start",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start"
],
"port": 3000
}
]
}
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ the real client modules against it, and the onboarding one writes real rows
SOFTTRACK_LIVE_URL=http://localhost:8000 npm test
```

Registration is throttled per IP and charged even on success, so running the
onboarding suite repeatedly will eventually 429. The counter lives in the API
process, so `docker compose restart backend` clears it without touching the
database.
The live suites between them register a handful of real accounts per run, and
registration is throttled per IP and charged even on success — so a few full runs
in a row will eventually 429. The counter lives in the API process, so
`docker compose restart backend && docker compose up -d` clears it without
touching the database.

Note `experiments.typedRoutes` is a dev-time aid: the route union is written by
`expo start`, not by `expo export`, so outside the dev server every path
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"axios": "^1.20.0",
"expo": "~57.0.22",
"expo-blur": "~57.0.3",
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.18",
"expo-document-picker": "~57.0.2",
"expo-file-system": "~57.0.7",
Expand Down
169 changes: 169 additions & 0 deletions src/api/__tests__/live-team-admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* @jest-environment node
*/
import {
loginAuthLoginPost,
registerAuthRegisterPost,
} from '@/api/generated/endpoints/auth/auth';
import {
acceptInviteInvitesTokenAcceptPost,
createInviteTeamsTeamIdInvitesPost,
listInvitesTeamsTeamIdInvitesGet,
revokeInviteTeamsTeamIdInvitesInviteIdDelete,
} from '@/api/generated/endpoints/invites/invites';
import {
createIssueTeamsTeamIdIssuesPost,
deleteIssueIssuesIssueIdDelete,
getIssueIssuesIssueIdGet,
} from '@/api/generated/endpoints/issues/issues';
import {
createStatusTeamsTeamIdStatusesPost,
deleteStatusStatusesStatusIdDelete,
listStatusesTeamsTeamIdStatusesGet,
reorderStatusesTeamsTeamIdStatusesOrderPut,
} from '@/api/generated/endpoints/statuses/statuses';
import {
createTeamTeamsPost,
listTeamMembersTeamsTeamIdMembersGet,
removeTeamMemberTeamsTeamIdMembersUserIdDelete,
updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch,
updateTeamTeamsTeamIdPatch,
} from '@/api/generated/endpoints/teams/teams';
import type { TeamRead } from '@/api/generated/models';
import { setInstanceUrl } from '@/api/instance';
import { persistToken } from '@/auth/session';

/**
* Team administration against a real instance.
*
* Works on a team created for the run rather than the demo one, because these
* delete statuses and change roles.
*/
const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
const describeLive = LIVE_URL ? describe : describe.skip;

const RUN = Date.now().toString(36).slice(-5);
const KEY = `T${RUN}`.replace(/[^A-Za-z]/g, 'X').toUpperCase().slice(0, 6);

describeLive('team administration against a live instance', () => {
let team: TeamRead;
let adminToken: string;
let otherToken: string;
let otherId: number;

beforeAll(async () => {
await setInstanceUrl(LIVE_URL!);
const admin = await loginAuthLoginPost({
username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev',
password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123',
});
adminToken = admin.access_token;
await persistToken(adminToken);

team = await createTeamTeamsPost({ name: `Admin ${RUN}`, key: KEY });

const other = await registerAuthRegisterPost({
email: `mob-admin-${RUN}@example.com`,
password: 'password123',
full_name: 'Second Admin',
});
otherToken = other.access_token;
otherId = other.user.id;
});

it('renames the team but never its key', async () => {
const updated = await updateTeamTeamsTeamIdPatch(team.id, {
name: `Renamed ${RUN}`,
description: 'Set from mobile',
});
expect(updated.name).toBe(`Renamed ${RUN}`);
expect(updated.description).toBe('Set from mobile');
// The key is permanent, which is why the field is read-only.
expect(updated.key).toBe(KEY);
});

it('invites, lists, and revokes', async () => {
const invite = await createInviteTeamsTeamIdInvitesPost(team.id, {
email: `revoke-${RUN}@example.com`,
role: 'member',
});
expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).toContain(
invite.id,
);

await revokeInviteTeamsTeamIdInvitesInviteIdDelete(team.id, invite.id);
expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).not.toContain(
invite.id,
);
});

it('refuses to leave the team without another admin', async () => {
// The guard the Manage sheet explains rather than discovers.
await expect(
removeTeamMemberTeamsTeamIdMembersUserIdDelete(team.id, otherId),
).rejects.toMatchObject({ response: { status: 404 } });

const members = await listTeamMembersTeamsTeamIdMembersGet(team.id);
const me = members[0];
await expect(
updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch(team.id, me.user.id, {
role: 'member',
}),
// 409: demoting the last admin conflicts with the team still needing one,
// which is the guard the Manage sheet explains up front.
).rejects.toMatchObject({ response: { status: 409 } });
});

it('promotes a second member to admin', async () => {
const invite = await createInviteTeamsTeamIdInvitesPost(team.id, {
email: `mob-admin-${RUN}@example.com`,
role: 'member',
});
await persistToken(otherToken);
await acceptInviteInvitesTokenAcceptPost(invite.token);

await persistToken(adminToken);
const promoted = await updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch(
team.id,
otherId,
{ role: 'admin' },
);
expect(promoted.role).toBe('admin');
});

it('creates, reorders and deletes a status, moving its issues', async () => {
const before = await listStatusesTeamsTeamIdStatusesGet(team.id);
const created = await createStatusTeamsTeamIdStatusesPost(team.id, {
name: `Review ${RUN}`,
category: 'started',
});

// Reordering sends every id at once, so two people cannot interleave into
// an order neither chose.
const reversed = [created.id, ...before.map((s) => s.id)];
const reordered = await reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, {
status_ids: reversed,
});
expect(reordered.map((s) => s.id)).toEqual(reversed);

const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `In deleted status ${RUN}`,
status_id: created.id,
});

const destination = before[0];
await deleteStatusStatusesStatusIdDelete(created.id, { move_to_id: destination.id });

// Where the issues go is required rather than guessed -- so they are
// exactly where they were sent.
expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(destination.id);
await deleteIssueIssuesIssueIdDelete(issue.id).catch(() => undefined);
});

it('will not delete a status without saying where its issues go', async () => {
const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id);
await expect(
deleteStatusStatusesStatusIdDelete(statuses[0].id, {} as never),
).rejects.toMatchObject({ response: { status: 422 } });
});
});
69 changes: 69 additions & 0 deletions src/team/admin/__tests__/automation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describeRule } from '@/team/admin/automation-panel';

const LOOK = {
status: (id: number) => (id === 1 ? 'In Progress' : 'a deleted status'),
label: (id: number) => (id === 2 ? 'bug' : 'a deleted label'),
project: (id: number) => (id === 3 ? 'Platform' : 'a deleted project'),
member: (id: number) => (id === 4 ? 'Ada Lovelace' : 'a former member'),
cycle: (id: number) => (id === 5 ? 'Cycle 14' : 'a deleted cycle'),
};

function rule(over: Record<string, unknown> = {}) {
return {
id: 1,
team_id: 1,
name: 'Rule',
trigger: 'issue_created',
is_enabled: true,
conditions: {},
actions: {},
created_by: { id: 1, username: 'a', full_name: 'A', avatar_color: '#000' },
created_at: '',
updated_at: '',
...over,
} as never as Parameters<typeof describeRule>[0];
}

describe('describeRule', () => {
it('reads as a sentence rather than a grid of fields', () => {
expect(
describeRule(rule({ actions: { set_priority: 'high' } }), LOOK),
).toBe('When an issue is created, set its priority to High.');
});

it('joins conditions with "and"', () => {
expect(
describeRule(
rule({
trigger: 'status_changed',
conditions: { if_status_id: 1, if_unassigned: true },
actions: { set_assignee_id: 4 },
}),
LOOK,
),
).toBe(
'When an issue changes status and it is in In Progress and it is unassigned, assign it to Ada Lovelace.',
);
});

it('joins multiple actions', () => {
expect(
describeRule(
rule({ actions: { add_label_id: 2, move_to_active_cycle: true } }),
LOOK,
),
).toBe('When an issue is created, add the bug label, and move it into the active cycle.');
});

it('names a row the team has since deleted rather than printing undefined', () => {
// A rule outlives the label it points at, and a sentence with "undefined"
// in it is worse than one that says the label is gone.
expect(describeRule(rule({ actions: { add_label_id: 99 } }), LOOK)).toContain(
'a deleted label',
);
});

it('says so when a rule does nothing', () => {
expect(describeRule(rule(), LOOK)).toBe('When an issue is created, do nothing.');
});
});
Loading
Loading