feat(dw): 🎸 parallel agent worktrees with isolated infra + test harness
bun dw spins up a fully isolated per-worktree dev stack: Neon DB branch, Dragonfly + ElasticMQ via docker compose, portless aliases, tmux session, and emulate.dev Google OAuth. Workers, dev server, vite, checkout, and stripe-listen run as concurrently siblings. bun setup-test auto-runs on first dw and seeds unit-test-org via clearOrg+setupOrg+ensureDefaultStripeAccount with 13 features + team invites for all 5 useautumn.com members. Hardcoded localhost URLs in test scenarios now read AUTUMN_TEST_BASE_URL/AUTUMN_TEST_VITE_URL. preload-env.ts loads .env.local so bun t/cm/setup-test route to the worktree server. 1265-line scripts/dw.ts split into scripts/dw/ module (index + commands + helpers). server/src/db/initDrizzle.ts reverted to upstream (search_path concerns solved by per-branch Neon isolation). Workers enabled in agent worktrees now that SQS is per-worktree via ElasticMQ.
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
import {
|
||||
AppEnv,
|
||||
invitation,
|
||||
member,
|
||||
type OrgConfig,
|
||||
organizations,
|
||||
user,
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@server/db/initDrizzle.js";
|
||||
import { createHardcodedKey } from "@server/internal/dev/api-keys/apiKeyUtils.js";
|
||||
import {
|
||||
createHardcodedKey,
|
||||
createKey,
|
||||
} from "@server/internal/dev/api-keys/apiKeyUtils.js";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { clearOrgDbOnly } from "@tests/utils/setup/clearOrg.js";
|
||||
import { setupOrg } from "@tests/utils/setup/setupOrg.js";
|
||||
import { ensureDefaultStripeAccount } from "./ensureDefaultStripeAccount.js";
|
||||
|
||||
const TEST_ORG_CONFIG = {
|
||||
id: "org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt",
|
||||
@@ -18,6 +25,22 @@ const TEST_ORG_CONFIG = {
|
||||
created_at: 1738583937426,
|
||||
};
|
||||
|
||||
// Synthetic inviter pinned to the test org; satisfies invitation.inviter_id
|
||||
// NOT-NULL FK without needing a real human user in a fresh worktree branch.
|
||||
const TEST_INVITER_USER = {
|
||||
id: "user_setup_test_inviter",
|
||||
name: "Setup Test Inviter",
|
||||
email: "setup-test-inviter@autumn.test",
|
||||
};
|
||||
|
||||
const TEAM_INVITE_EMAILS = [
|
||||
"ayush@useautumn.com",
|
||||
"jy@useautumn.com",
|
||||
"tanvir@useautumn.com",
|
||||
"charlie@useautumn.com",
|
||||
"owen@useautumn.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a test organization in the database and generates an API key
|
||||
*/
|
||||
@@ -33,11 +56,6 @@ export async function createTestOrg({
|
||||
);
|
||||
|
||||
const TEST_API_KEY = process.env.UNIT_TEST_AUTUMN_SECRET_KEY;
|
||||
if (!TEST_API_KEY) {
|
||||
throw new Error(
|
||||
"UNIT_TEST_AUTUMN_SECRET_KEY is not set (is infisical running?)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if org already exists
|
||||
const existingOrg = await db.query.organizations.findFirst({
|
||||
@@ -51,27 +69,52 @@ export async function createTestOrg({
|
||||
),
|
||||
);
|
||||
|
||||
// Create API key for existing org (will skip if already exists)
|
||||
const { key, alreadyExists } = await createHardcodedKey({
|
||||
await seedTeamInvites({ db });
|
||||
await ensureDefaultStripeAccount({ db, orgId: TEST_ORG_CONFIG.id });
|
||||
await clearOrgDbOnly({ db, orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox });
|
||||
await setupOrg({ orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox });
|
||||
|
||||
if (TEST_API_KEY) {
|
||||
const { key, alreadyExists } = await createHardcodedKey({
|
||||
db,
|
||||
env: AppEnv.Sandbox,
|
||||
name: "Unit Test Key",
|
||||
orgId: TEST_ORG_CONFIG.id,
|
||||
hardcodedKey: TEST_API_KEY,
|
||||
meta: {
|
||||
createdBy: "setup-test-script",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
alreadyExists
|
||||
? "✅ API key already exists in database"
|
||||
: "✅ Created API key for existing organization",
|
||||
),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
// No hardcoded key in env; generate a fresh one for the existing org.
|
||||
const generated = await createKey({
|
||||
db,
|
||||
env: AppEnv.Sandbox,
|
||||
name: "Unit Test Key",
|
||||
orgId: TEST_ORG_CONFIG.id,
|
||||
hardcodedKey: TEST_API_KEY,
|
||||
prefix: "am_sk_test_",
|
||||
meta: {
|
||||
createdBy: "setup-test-script",
|
||||
createdAt: new Date().toISOString(),
|
||||
autogenerated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (alreadyExists) {
|
||||
console.log(chalk.greenBright("✅ API key already exists in database"));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.greenBright("✅ Created API key for existing organization"),
|
||||
);
|
||||
}
|
||||
return key;
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
"✅ Generated fresh API key (UNIT_TEST_AUTUMN_SECRET_KEY unset)",
|
||||
),
|
||||
);
|
||||
return generated;
|
||||
}
|
||||
|
||||
// Create the test organization
|
||||
@@ -95,55 +138,135 @@ export async function createTestOrg({
|
||||
),
|
||||
);
|
||||
|
||||
// Get first 5 users from database and create memberships
|
||||
const users = await db.select().from(user).limit(5);
|
||||
|
||||
if (users.length > 0) {
|
||||
const { generateId } = await import("@server/utils/genUtils.js");
|
||||
|
||||
const memberships = users.map((u) => ({
|
||||
id: generateId("mem"),
|
||||
organizationId: TEST_ORG_CONFIG.id,
|
||||
userId: u.id,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
}));
|
||||
|
||||
await db.insert(member).values(memberships);
|
||||
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
`✅ Created ${memberships.length} membership(s) for test organization`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
chalk.yellowBright(
|
||||
"⚠ No users found in database. Skipping membership creation.",
|
||||
),
|
||||
);
|
||||
}
|
||||
await seedTeamInvites({ db });
|
||||
await ensureDefaultStripeAccount({ db, orgId: TEST_ORG_CONFIG.id });
|
||||
await clearOrgDbOnly({ db, orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox });
|
||||
await setupOrg({ orgId: TEST_ORG_CONFIG.id, env: AppEnv.Sandbox });
|
||||
|
||||
// Create API key for the new org
|
||||
const { key, alreadyExists } = await createHardcodedKey({
|
||||
if (TEST_API_KEY) {
|
||||
const { key, alreadyExists } = await createHardcodedKey({
|
||||
db,
|
||||
env: AppEnv.Sandbox,
|
||||
name: "Unit Test Key",
|
||||
orgId: TEST_ORG_CONFIG.id,
|
||||
hardcodedKey: TEST_API_KEY,
|
||||
meta: {
|
||||
createdBy: "setup-test-script",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
alreadyExists
|
||||
? "✅ API key already exists in database"
|
||||
: "✅ Created API key for test organization",
|
||||
),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
const generated = await createKey({
|
||||
db,
|
||||
env: AppEnv.Sandbox,
|
||||
name: "Unit Test Key",
|
||||
orgId: TEST_ORG_CONFIG.id,
|
||||
hardcodedKey: TEST_API_KEY,
|
||||
prefix: "am_sk_test_",
|
||||
meta: {
|
||||
createdBy: "setup-test-script",
|
||||
createdAt: new Date().toISOString(),
|
||||
autogenerated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (alreadyExists) {
|
||||
console.log(chalk.greenBright("✅ API key already exists in database"));
|
||||
} else {
|
||||
console.log(chalk.greenBright("✅ Created API key for test organization"));
|
||||
}
|
||||
|
||||
return key;
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
"✅ Generated fresh API key (UNIT_TEST_AUTUMN_SECRET_KEY unset)",
|
||||
),
|
||||
);
|
||||
return generated;
|
||||
}
|
||||
|
||||
export { TEST_ORG_CONFIG };
|
||||
|
||||
// Per-email: insert a member row if a user already exists, otherwise insert
|
||||
// an invitation row. Both paths skip if a matching member/invitation already
|
||||
// targets this org+email — safe to re-run. Note: better-auth does NOT auto-
|
||||
// accept invitations on sign-in. Invitees must visit /accept?id=<inv_id> (or
|
||||
// equivalent UI) after signing in to finish joining the org.
|
||||
async function seedTeamInvites({ db }: { db: DrizzleCli }): Promise<void> {
|
||||
const { generateId } = await import("@server/utils/genUtils.js");
|
||||
|
||||
// Ensure a synthetic inviter exists so invitation.inviter_id FK resolves.
|
||||
await db
|
||||
.insert(user)
|
||||
.values({
|
||||
id: TEST_INVITER_USER.id,
|
||||
name: TEST_INVITER_USER.name,
|
||||
email: TEST_INVITER_USER.email,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(inArray(user.email, TEAM_INVITE_EMAILS));
|
||||
const userByEmail = new Map(existingUsers.map((u) => [u.email, u]));
|
||||
|
||||
const existingMembers = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, TEST_ORG_CONFIG.id));
|
||||
const membershipUserIds = new Set(existingMembers.map((m) => m.userId));
|
||||
|
||||
const existingInvitesForOrg = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, TEST_ORG_CONFIG.id),
|
||||
inArray(invitation.email, TEAM_INVITE_EMAILS),
|
||||
),
|
||||
);
|
||||
const inviteEmails = new Set(existingInvitesForOrg.map((i) => i.email));
|
||||
|
||||
let membersCreated = 0;
|
||||
let invitesCreated = 0;
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const email of TEAM_INVITE_EMAILS) {
|
||||
const existingUser = userByEmail.get(email);
|
||||
if (existingUser) {
|
||||
if (membershipUserIds.has(existingUser.id)) continue;
|
||||
await db.insert(member).values({
|
||||
id: generateId("mem"),
|
||||
organizationId: TEST_ORG_CONFIG.id,
|
||||
userId: existingUser.id,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
membersCreated++;
|
||||
} else {
|
||||
if (inviteEmails.has(email)) continue;
|
||||
await db.insert(invitation).values({
|
||||
id: generateId("inv"),
|
||||
organizationId: TEST_ORG_CONFIG.id,
|
||||
email,
|
||||
role: "owner",
|
||||
status: "pending",
|
||||
createdAt: new Date(),
|
||||
expiresAt,
|
||||
inviterId: TEST_INVITER_USER.id,
|
||||
});
|
||||
invitesCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
`✅ Team seed: ${membersCreated} member(s) + ${invitesCreated} invitation(s) for ${TEAM_INVITE_EMAILS.length} email(s)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
59
scripts/setupTestUtils/ensureDefaultStripeAccount.ts
Normal file
59
scripts/setupTestUtils/ensureDefaultStripeAccount.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import chalk from "chalk";
|
||||
import type { DrizzleCli } from "@server/db/initDrizzle.js";
|
||||
import { OrgService } from "@server/internal/orgs/OrgService.js";
|
||||
import { createConnectAccount } from "@server/internal/orgs/orgUtils/createConnectAccount.js";
|
||||
|
||||
const DUMMY_USER = {
|
||||
id: "setup-test-stripe-user",
|
||||
email: "setup-test@autumn.test",
|
||||
name: "Setup Test User",
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotently ensure the test org has a default Stripe Connect sandbox
|
||||
* account. Creates one only if `test_stripe_connect.default_account_id`
|
||||
* is missing.
|
||||
*/
|
||||
export async function ensureDefaultStripeAccount({
|
||||
db,
|
||||
orgId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
}): Promise<void> {
|
||||
const org = await OrgService.get({ db, orgId });
|
||||
|
||||
const existingAccountId = org.test_stripe_connect?.default_account_id;
|
||||
if (existingAccountId) {
|
||||
console.log(
|
||||
chalk.yellowBright(
|
||||
`Stripe default account already connected (${existingAccountId}). Skipping.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.blue(" 🔄 Creating default Stripe sandbox account..."));
|
||||
|
||||
const newAccount = await createConnectAccount({
|
||||
org: org as any,
|
||||
user: DUMMY_USER as any,
|
||||
});
|
||||
|
||||
await OrgService.update({
|
||||
db,
|
||||
orgId,
|
||||
updates: {
|
||||
test_stripe_connect: {
|
||||
...org.test_stripe_connect,
|
||||
default_account_id: newAccount.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
chalk.greenBright(
|
||||
` ✅ Created default Stripe sandbox account: ${newAccount.id}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user