Fix tests

This commit is contained in:
Konstantin Wohlwend 2026-02-17 19:57:07 -08:00
parent 145bcb7e92
commit 77787c3a4d
7 changed files with 102 additions and 72 deletions

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ node-compile-cache/
.envrc
debug.log
*.cpuprofile

View File

@ -12,7 +12,7 @@ This file provides guidance to coding agents when working with code in this repo
#### Extra commands
These commands are usually already called by the user, but you can remind them to run it for you if they forgot to.
- **Build packages**: `pnpm build:packages` (you should never call this yourself)
- **Build packages**: NEVER DO THIS YOURSELF — ASK THE USER TO DO IT FOR YOU!
- **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user)
- **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc. Do NOT call build:packages, dev, codegen, or anything like that yourself, as the dev is already running it.
- **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems)

View File

@ -84,8 +84,26 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.VerificationCodeNotFound();
}
// Atomically mark the invitation as used before creating the membership.
// This uses globalPrismaClient (not a tenancy transaction), so it must happen
// outside retryTransaction to avoid being re-executed on retry after already committing.
const updated = await globalPrismaClient.verificationCode.updateMany({
where: {
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
id: params.id,
usedAt: null,
},
data: {
usedAt: new Date(),
},
});
if (updated.count === 0) {
throw new KnownErrors.VerificationCodeNotFound();
}
await retryTransaction(prisma, async (tx) => {
// Internal project payment checks (same as in the verification code handler)
if (auth.tenancy.project.id === "internal") {
const currentMemberCount = await tx.teamMember.count({
where: {
@ -123,23 +141,6 @@ export const POST = createSmartRouteHandler({
data: {},
});
}
// Mark the invitation as used inside the transaction to prevent race conditions
const updated = await globalPrismaClient.verificationCode.updateMany({
where: {
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
id: params.id,
usedAt: null,
},
data: {
usedAt: new Date(),
},
});
if (updated.count === 0) {
throw new KnownErrors.VerificationCodeNotFound();
}
});
return {

View File

@ -849,11 +849,20 @@ it("should allow a restricted user to accept invitation after verifying email",
it("can list invitations by user_id on the server", async ({ expect }) => {
await Project.createAndSwitch();
const { userId: inviter } = await Auth.fastSignUp();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox.emailAddress, teamId);
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Create a new user with the invited email as a verified contact channel
const { userId: invitedUserId } = await Auth.fastSignUp({
@ -866,34 +875,29 @@ it("can list invitations by user_id on the server", async ({ expect }) => {
accessType: "server",
method: "GET",
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"expires_at_millis": <stripped field 'expires_at_millis'>,
"id": "<stripped UUID>",
"recipient_email": "${receiveMailbox.emailAddress}",
"team_display_name": "New Team",
"team_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
expect(listResponse.status).toBe(200);
expect(listResponse.body.items).toHaveLength(1);
expect(listResponse.body.items[0].recipient_email).toBe(receiveMailbox.emailAddress);
expect(listResponse.body.items[0].team_display_name).toBe("New Team");
});
it("can list invitations by user_id=me on the client", async ({ expect }) => {
await Project.createAndSwitch();
const { userId: inviter } = await Auth.fastSignUp();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox.emailAddress, teamId);
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Sign up as the invited user with verified email
backendContext.set({ mailbox: receiveMailbox });
@ -907,24 +911,10 @@ it("can list invitations by user_id=me on the client", async ({ expect }) => {
accessType: "client",
method: "GET",
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"expires_at_millis": <stripped field 'expires_at_millis'>,
"id": "<stripped UUID>",
"recipient_email": "${receiveMailbox.emailAddress}",
"team_display_name": "New Team",
"team_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
expect(listResponse.status).toBe(200);
expect(listResponse.body.items).toHaveLength(1);
expect(listResponse.body.items[0].recipient_email).toBe(receiveMailbox.emailAddress);
expect(listResponse.body.items[0].team_display_name).toBe("New Team");
});
@ -932,7 +922,17 @@ it("returns empty list when user has no verified emails matching invitations", a
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
await Team.sendInvitation("unrelated@example.com", teamId);
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: "unrelated@example.com",
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Sign up as a different user
const { userId: otherUserId } = await Auth.fastSignUp({
@ -967,7 +967,16 @@ it("does not return invitations for unverified emails", async ({ expect }) => {
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox.emailAddress, teamId);
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Create a user with the same email but NOT verified
const { userId: unverifiedUserId } = await Auth.fastSignUp({
@ -1034,7 +1043,7 @@ it("client cannot list invitations for a user_id other than 'me'", async ({ expe
"status": 400,
"body": {
"code": "CANNOT_GET_OWN_USER_WITHOUT_USER",
"error": "You have specified 'me' as a userId, but did not provide authentication for a user. Make sure to pass the x-stack-access-token header to authenticate as a user.",
"error": "You have specified 'me' as a userId, but did not provide authentication for a user.",
},
"headers": Headers {
"x-stack-known-error": "CANNOT_GET_OWN_USER_WITHOUT_USER",
@ -1051,8 +1060,16 @@ it("can accept invitation by ID", async ({ expect }) => {
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
const { sendTeamInvitationResponse } = await Team.sendInvitation(receiveMailbox.emailAddress, teamId);
const invitationId = sendTeamInvitationResponse.body.id;
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Sign up as the invited user with the matching verified email
backendContext.set({ mailbox: receiveMailbox });
@ -1061,6 +1078,15 @@ it("can accept invitation by ID", async ({ expect }) => {
primary_email_verified: true,
});
// List to get the invitation ID
const listBeforeAccept = await niceBackendFetch(`/api/v1/team-invitations?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(listBeforeAccept.status).toBe(200);
expect(listBeforeAccept.body.items).toHaveLength(1);
const invitationId = listBeforeAccept.body.items[0].id;
// Accept the invitation by ID
const acceptResponse = await niceBackendFetch(`/api/v1/team-invitations/${invitationId}/accept?user_id=me`, {
accessType: "client",

View File

@ -3,7 +3,7 @@ import { createApp } from "./js-helpers";
it("should list team invitations for the current user via the client SDK", async ({ expect }) => {
const { clientApp, serverApp } = await createApp();
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create a team via a signed-in user
await clientApp.signUpWithCredential({
@ -58,7 +58,7 @@ it("should list team invitations for the current user via the client SDK", async
it("should return empty invitations when user has no matching invitations", async ({ expect }) => {
const { clientApp } = await createApp();
const { clientApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
await clientApp.signUpWithCredential({
email: "no-invites@test.com",
@ -77,7 +77,7 @@ it("should return empty invitations when user has no matching invitations", asyn
it("should list team invitations for a server user", async ({ expect }) => {
const { clientApp, serverApp } = await createApp();
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create team owner and team
await clientApp.signUpWithCredential({
@ -117,7 +117,7 @@ it("should list team invitations for a server user", async ({ expect }) => {
it("should not return invitations for unverified emails", async ({ expect }) => {
const { clientApp, serverApp } = await createApp();
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create team and invite an email
await clientApp.signUpWithCredential({
@ -152,7 +152,7 @@ it("should not return invitations for unverified emails", async ({ expect }) =>
it("should list invitations from multiple teams", async ({ expect }) => {
const { clientApp, serverApp } = await createApp();
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create two teams
await clientApp.signUpWithCredential({
@ -199,7 +199,7 @@ it("should list invitations from multiple teams", async ({ expect }) => {
it("should accept a team invitation via the client SDK", async ({ expect }) => {
const { clientApp, serverApp } = await createApp();
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create a team
await clientApp.signUpWithCredential({
@ -257,7 +257,7 @@ it("should accept a team invitation via the client SDK", async ({ expect }) => {
it("should accept a team invitation via the server SDK", async ({ expect }) => {
const { clientApp, serverApp } = await createApp();
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create team
await clientApp.signUpWithCredential({

View File

@ -1247,7 +1247,7 @@ export class StackClientInterface {
session: InternalSession,
) {
await this.sendClientRequest(
urlString`/team-invitations/${invitationId}/accept?` + new URLSearchParams({ user_id: 'me' }),
urlString`/team-invitations/${invitationId}/accept` + "?" + new URLSearchParams({ user_id: 'me' }),
{ method: "POST" },
session,
);

View File

@ -4,6 +4,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [tsconfigPaths() as any],
test: {
watch: false,
pool: 'threads',
maxWorkers: 8,
include: ['**/*.test.{js,ts,jsx,tsx}'],