stack/apps/e2e/tests/js/team-invitations.test.ts
Aman Ganapathy 1de8a17183
Payments bulldozer txn rework (#1315)
### Object of this PR
This PR is NOT a monolithic series of fixes for the payments suite + a
complete rework. Its aims were
a) introducing and robustly testing the bulldozer db system 
b) reworking the payments underlying architecture to use bulldozer for
correctness and scalability
c) Achieving parity with the old payments system excepting a few changes
like ensuring correctness of the ledger algo
There may still be some work to do with handling refunds, decoupling the
concepts of purchases from that of products, and some other things.

### Ledger Algorithm
This has been tuned and fixed. Item removals i.e negative item quantity
changes will apply to the soonest expiring item grant i.e positive item
quantity change. This is what is best for the user. Item grants can also
expire, and when they expire we obviate whatever is left of their
original capacity (meaning after all the removals that were applied to
it). Our ledger algo is applied via Bulldozer, so automatic
re-computation is handled when a new grant/ removal is inserted in the
middle of the existing ones.

### Things we got rid of 
* No more automatic support for default products. You can use $0 plan
provisions to accomplish the same effect but it's manual
* Negative item quantity changes (i.e item removals) no longer can have
expiries



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced payment processing pipeline with improved data consistency
and state management.
  * Advanced refund handling with comprehensive transaction tracking.
* Better tracking and management of customer item quantities and owned
products.
* Improved subscription lifecycle management including period-end
handling.

* **Bug Fixes**
  * Fixed payment data integrity verification.
  * Improved handling of edge cases in refund scenarios.

* **Chores**
  * Updated cSpell configuration with additional words.
  * Expanded developer documentation for linting workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
Co-authored-by: Aadesh Kheria <kheriaaadesh@gmail.com>
Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com>
2026-04-17 22:11:21 +00:00

302 lines
11 KiB
TypeScript

import { it } from "../helpers";
import { createApp } from "./js-helpers";
it("should list team invitations for the current user via the client SDK", { timeout: 60_000 }, async ({ expect }) => {
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create a team via a signed-in user
await clientApp.signUpWithCredential({
email: "team-owner@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "team-owner@test.com",
password: "password",
});
const owner = await clientApp.getUser({ or: "throw" });
const team = await owner.createTeam({ displayName: "Inviting Team" });
// Invite a specific email address via the server SDK
const serverTeam = await serverApp.getTeam(team.id);
if (!serverTeam) throw new Error("Team not found on server");
await serverTeam.inviteUser({
email: "invited-user@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
// Create a new user with that verified email and sign in
await clientApp.signUpWithCredential({
email: "invited-user@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "invited-user@test.com",
password: "password",
});
// Verify the email server-side so the invitation lookup will find it
const createdUser = await clientApp.getUser({ or: "throw" });
const serverUser = await serverApp.getUser(createdUser.id);
if (!serverUser) throw new Error("User not found on server");
await serverUser.update({ primaryEmailVerified: true });
// Re-fetch user after verification
const user = await clientApp.getUser({ or: "throw" });
// List team invitations for the current user
const invitations = await user.listTeamInvitations();
expect(invitations).toHaveLength(1);
expect(invitations[0].teamId).toBe(team.id);
expect(invitations[0].teamDisplayName).toBe("Inviting Team");
expect(invitations[0].recipientEmail).toBe("invited-user@test.com");
expect(invitations[0].expiresAt).toBeInstanceOf(Date);
expect(invitations[0].expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it("should return empty invitations when user has no matching invitations", async ({ expect }) => {
const { clientApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
await clientApp.signUpWithCredential({
email: "no-invites@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "no-invites@test.com",
password: "password",
});
const user = await clientApp.getUser({ or: "throw" });
const invitations = await user.listTeamInvitations();
expect(invitations).toHaveLength(0);
});
it("should list team invitations for a server user", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create team owner and team
await clientApp.signUpWithCredential({
email: "server-owner@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "server-owner@test.com",
password: "password",
});
const owner = await clientApp.getUser({ or: "throw" });
const team = await owner.createTeam({ displayName: "Server Test Team" });
// Create a user with a verified email via the server SDK
const invitedServerUser = await serverApp.createUser({
primaryEmail: "server-invited@test.com",
primaryEmailVerified: true,
});
// Send an invitation to that email
const serverTeam = await serverApp.getTeam(team.id);
if (!serverTeam) throw new Error("Team not found on server");
await serverTeam.inviteUser({
email: "server-invited@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
// List invitations via the server user object
const invitations = await invitedServerUser.listTeamInvitations();
expect(invitations).toHaveLength(1);
expect(invitations[0].teamId).toBe(team.id);
expect(invitations[0].teamDisplayName).toBe("Server Test Team");
expect(invitations[0].recipientEmail).toBe("server-invited@test.com");
});
it("should not return invitations for unverified emails", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create team and invite an email
await clientApp.signUpWithCredential({
email: "unverified-owner@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "unverified-owner@test.com",
password: "password",
});
const owner = await clientApp.getUser({ or: "throw" });
const team = await owner.createTeam({ displayName: "Unverified Test" });
const serverTeam = await serverApp.getTeam(team.id);
if (!serverTeam) throw new Error("Team not found on server");
await serverTeam.inviteUser({
email: "unverified-target@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
// Create a user with that email but leave it unverified
const unverifiedUser = await serverApp.createUser({
primaryEmail: "unverified-target@test.com",
primaryEmailVerified: false,
});
// Invitations should be empty because the email is not verified
const invitations = await unverifiedUser.listTeamInvitations();
expect(invitations).toHaveLength(0);
});
it("should list invitations from multiple teams", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create two teams
await clientApp.signUpWithCredential({
email: "multi-owner@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "multi-owner@test.com",
password: "password",
});
const owner = await clientApp.getUser({ or: "throw" });
const team1 = await owner.createTeam({ displayName: "Team Alpha" });
const team2 = await owner.createTeam({ displayName: "Team Beta" });
// Create the invited user with a verified email
const invitedUser = await serverApp.createUser({
primaryEmail: "multi-invited@test.com",
primaryEmailVerified: true,
});
// Send invitations from both teams
const serverTeam1 = await serverApp.getTeam(team1.id);
if (!serverTeam1) throw new Error("Team 1 not found");
await serverTeam1.inviteUser({
email: "multi-invited@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
const serverTeam2 = await serverApp.getTeam(team2.id);
if (!serverTeam2) throw new Error("Team 2 not found");
await serverTeam2.inviteUser({
email: "multi-invited@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
// List invitations
const invitations = await invitedUser.listTeamInvitations();
expect(invitations).toHaveLength(2);
const teamNames = invitations.map(i => i.teamDisplayName).sort();
expect(teamNames).toEqual(["Team Alpha", "Team Beta"]);
});
it("should accept a team invitation via the client SDK", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create a team
await clientApp.signUpWithCredential({
email: "accept-owner@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "accept-owner@test.com",
password: "password",
});
const owner = await clientApp.getUser({ or: "throw" });
const team = await owner.createTeam({ displayName: "Accept Test Team" });
// Invite a user
const serverTeam = await serverApp.getTeam(team.id);
if (!serverTeam) throw new Error("Team not found on server");
await serverTeam.inviteUser({
email: "accept-user@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
// Sign up as the invited user with verified email
await clientApp.signUpWithCredential({
email: "accept-user@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "accept-user@test.com",
password: "password",
});
const createdUser = await clientApp.getUser({ or: "throw" });
const serverCreatedUser = await serverApp.getUser(createdUser.id);
if (!serverCreatedUser) throw new Error("User not found on server");
await serverCreatedUser.update({ primaryEmailVerified: true });
// List and accept the invitation
const user = await clientApp.getUser({ or: "throw" });
const invitations = await user.listTeamInvitations();
expect(invitations).toHaveLength(1);
await invitations[0].accept();
// Verify user is now a member of the team
const teams = await user.listTeams();
const joinedTeam = teams.find(t => t.id === team.id);
expect(joinedTeam).toBeDefined();
expect(joinedTeam!.displayName).toBe("Accept Test Team");
// Invitation should no longer be listed (it was used)
const remainingInvitations = await user.listTeamInvitations();
expect(remainingInvitations).toHaveLength(0);
});
it("should accept a team invitation via the server SDK", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
// Create team
await clientApp.signUpWithCredential({
email: "server-accept-owner@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "server-accept-owner@test.com",
password: "password",
});
const owner = await clientApp.getUser({ or: "throw" });
const team = await owner.createTeam({ displayName: "Server Accept Team" });
// Create user and send invitation
const invitedUser = await serverApp.createUser({
primaryEmail: "server-accept@test.com",
primaryEmailVerified: true,
});
const serverTeam = await serverApp.getTeam(team.id);
if (!serverTeam) throw new Error("Team not found on server");
await serverTeam.inviteUser({
email: "server-accept@test.com",
callbackUrl: "http://localhost:3000/team-invite",
});
// Accept via server user
const invitations = await invitedUser.listTeamInvitations();
expect(invitations).toHaveLength(1);
await invitations[0].accept();
// Verify membership
const teams = await invitedUser.listTeams();
const joinedTeam = teams.find(t => t.id === team.id);
expect(joinedTeam).toBeDefined();
// Invitation consumed
const remaining = await invitedUser.listTeamInvitations();
expect(remaining).toHaveLength(0);
});