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
71 changes: 71 additions & 0 deletions e2e/full/invite-signup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,75 @@ test.describe("User Invitation & Signup Flow", () => {
await expect(page.getByText("Full Flow")).toBeVisible();
// Email is no longer displayed in user menu (removed in PR #871)
});

test("should transfer machine ownership when invited user signs up", async ({
page,
}) => {
test.slow(); // Triple the timeout - complex multi-step flow
const testId = Math.random().toString(36).substring(7);
const userEmail = `owner-invite-${testId}@example.com`;
testEmails.add(userEmail);

// 1. Invite user via admin panel
await page.goto("/admin/users");
await page.getByRole("button", { name: /Invite User/i }).click();
await page.getByLabel(/First Name/i).fill("Owner");
await page.getByLabel(/Last Name/i).fill("Transfer");
await page.getByRole("textbox", { name: "Email" }).fill(userEmail);

const inviteSwitch = page.getByRole("switch", {
name: /Send invitation email/i,
});
if ((await inviteSwitch.getAttribute("aria-checked")) === "false") {
await inviteSwitch.click();
}

await page
.getByRole("button", { name: /Invite User/i, includeHidden: false })
.click();
await expect(page.getByRole("dialog")).not.toBeVisible();

// 2. Assign the invited user as owner of a machine (use Humpty Dumpty - HD)
await page.goto("/m/HD");
await expect(
page.getByRole("heading", { name: /Humpty Dumpty/i })
).toBeVisible();

// Click the owner dropdown and select the invited user (shown with "(Invited)" suffix)
const ownerSelect = page.getByTestId("owner-select");
await ownerSelect.click();
await page
.getByRole("option", { name: /Owner Transfer.*\(Invited\)/i })
.click();

// Save the machine
await page.getByRole("button", { name: /Update Machine/i }).click();
await expect(page.getByText(/Machine updated/i)).toBeVisible();

// 3. Logout and complete signup
await logout(page);

Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard-coded delay should include an explanatory comment like the one on line 82-83 of this file. The comment should explain that this delay allows backend processing time for email transmission and that getSignupLink performs internal polling. This helps future maintainers understand why the delay is necessary.

Suggested change
// Allow backend processing time for the invitation email to be transmitted.
// getSignupLink performs internal polling against Mailpit to retrieve the message.

Copilot uses AI. Check for mistakes.
await new Promise((resolve) => setTimeout(resolve, 2000));
const signupLink = await getSignupLink(userEmail);
await page.goto(signupLink);

await page.getByLabel("Password", { exact: true }).fill("TestPassword123!");
await page.getByLabel(/Confirm Password/i).fill("TestPassword123!");
await page.getByRole("button", { name: /Create Account/i }).click();

await expect(page).toHaveURL("/dashboard", { timeout: 15000 });

// 4. Verify machine ownership transferred
await page.goto("/m/HD");
await expect(
page.getByRole("heading", { name: /Humpty Dumpty/i })
).toBeVisible();

// The owner display should show the real user name without "(Invited)" suffix
// Note: User is a member now, so they see the read-only owner display
const ownerDisplay = page.getByTestId("owner-display");
await expect(ownerDisplay).toContainText("Owner Transfer");
// After signup, the user is no longer "invited" - they're a real user
await expect(ownerDisplay).not.toContainText("(Invited)");
});
});
29 changes: 29 additions & 0 deletions src/app/api/test-data/cleanup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,35 @@ export async function POST(request: Request): Promise<Response> {

const removedUsers: string[] = [];
if (userEmails.length) {
// First, get user IDs from both invited_users and auth.users
const invitedUserIds = await db
.select({ id: invitedUsers.id })
.from(invitedUsers)
.where(inArray(invitedUsers.email, userEmails));

const authUserIds = await db
.select({ id: authUsers.id })
.from(authUsers)
.where(inArray(authUsers.email, userEmails));

const allUserIds = [
...invitedUserIds.map((r) => r.id),
...authUserIds.map((r) => r.id),
];

// Clear machine ownership references BEFORE deleting users (FK constraint)
if (allUserIds.length) {
await db
.update(machines)
.set({ ownerId: null, invitedOwnerId: null })
.where(
or(
inArray(machines.ownerId, allUserIds),
inArray(machines.invitedOwnerId, allUserIds)
)
);
}

const deletedInvited = await db
.delete(invitedUsers)
.where(inArray(invitedUsers.email, userEmails))
Expand Down
1 change: 1 addition & 0 deletions src/components/machines/OwnerSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function OwnerSelect({
id="ownerId"
className="border-outline bg-surface text-on-surface"
aria-describedby="owner-help"
data-testid="owner-select"
>
<SelectValue placeholder="Select an owner" />
</SelectTrigger>
Expand Down
Loading