Bulletproofing Multi-Tenant Apps: E2E Tests and Pgvector-Safe Migrations
A deep dive into implementing robust end-to-end tests for complex superadmin and tenant-switching flows, alongside crafting a bulletproof migration script for pgvector-enabled databases, complete with hard-won lessons from the trenches.
Just wrapped up a session that felt like hitting a couple of critical milestones in our nyxcore.cloud journey. The kind of session where you tackle two seemingly disparate but equally vital areas: ensuring rock-solid E2E test coverage for our multi-tenant core, and building a production-grade safety net for database migrations involving potentially destructive pgvector columns.
The goal was clear: implement comprehensive E2E tests for superadmin and tenant-switching flows, and then create a "pgvector-safe" production migration script. I'm happy to report: mission accomplished. All 72 E2E tests are passing, typecheck is clean, and the codebase is ready for its next commit.
The Multi-Tenant E2E Odyssey
Building a multi-tenant application comes with its own set of testing complexities. The core challenge is ensuring that data isolation works flawlessly, that tenants can only see and interact with their own data, and that superadmins have the power to manage everything without breaking anything. Manual testing these flows is a recipe for disaster and burnout. Enter Playwright.
Our existing E2E suite had a good foundation, but the superadmin and tenant-switching mechanisms were a blind spot. This session focused on bringing them into the light.
Crafting the Testing Toolkit
To efficiently test these authenticated and permission-gated flows, I extended our tests/e2e/helpers/auth.ts file with two crucial helpers:
injectSuperAdminCookie(): This helper ensures that our Playwright context is authenticated as a superadmin, giving us full access to all privileged routes and components.injectTenantCookie(): Similarly, this allows us to quickly switch the active tenant context for a given test, crucial for verifying data isolation and tenant-specific features.
These helpers abstract away the nitty-gritty of session management, allowing our tests to focus purely on user behavior and application logic.
Comprehensive Coverage for Core Flows
With the helpers in place, I spun up tests/e2e/superadmin.spec.ts, which now houses 16 distinct test cases (running across Chromium and mobile, totaling 32 tests for this file alone!). We covered:
- Tenant Switcher: Verifying visibility, successful switching between tenants, the active tenant checkmark, and the "Other tenants" section for non-members.
- Superadmin Page: Ensuring all three tabs (Tenants, Users, Invitations) render correctly and, critically, that non-superadmins are denied access.
- Tenant Management: Listing tenants, expanding members, and testing the create dialog and its associated mutation.
- User Management: Listing users, confirming the superadmin badge is displayed correctly.
- Invitations: Listing invitations and verifying the empty state.
- Invite Dialog: Testing the opening, filling, and submission of the invite mutation.
- Data Isolation: A cornerstone test — confirming that switching tenants correctly triggers a data mutation (e.g., loading new data) and that non-member tenants correctly show a "join" prompt.
This level of detail dramatically boosts our confidence in the core multi-tenant functionality.
The Database Migration Safety Net
Database migrations are always a high-stakes game. When you're dealing with vector embeddings (pgvector) and potentially derived searchVector columns, the stakes get even higher. A careless DROP COLUMN operation could lead to catastrophic data loss or, at best, a very painful recovery process.
Our existing migration process relied on prisma migrate diff to generate SQL, but it didn't have a built-in safety mechanism for these specific columns.
Introducing scripts/db-migrate-safe.sh
To mitigate this risk, I developed a new shell script: scripts/db-migrate-safe.sh. This script is designed to be a robust, review-first approach to applying schema changes, especially when pgvector is in play.
Here's how it works:
- SQL Diff Generation: It uses
prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel "$PRISMA_SCHEMA_PATH" --scriptto generate the raw SQL needed to bring the database schema in sync with our Prisma schema. - Pgvector-Safe Filtering: This is the magic step. The script automatically filters out any
DROP COLUMNstatements that targetembeddingorsearchVectorcolumns. This prevents accidental data loss for these critical fields.bash# Example of the core filtering logic SQL_DIFF=$(prisma migrate diff ...) FILTERED_SQL=$(echo "$SQL_DIFF" | grep -v 'ALTER TABLE .* DROP COLUMN .* (embedding|searchVector)') - Review First: Before any changes are applied, the filtered SQL is displayed for manual review. This
--dry-runcapability is essential. - Application: Once reviewed and approved (via the
--applyflag), the script applies the filtered SQL usingpsql. - RLS Re-application: Finally, it re-runs our
rls.sqlscript. This is crucial because schema changes (like adding or dropping columns) can sometimes invalidate or require re-application of Row Level Security policies, ensuring data isolation remains intact.
This script provides a critical layer of safety, allowing us to evolve our schema with confidence, even with complex data types.
Lessons from the Trenches: The "Pain Log" Transformed
Not everything was smooth sailing. Here are a few bumps along the road and the lessons learned:
1. Playwright Locators: When getByText Isn't Enough
- The Problem: I initially tried to use
getByText('nyxCore'),getByText('Clarait'), orgetByText('superadmin')to locate elements in Playwright tests. This quickly led to "strict mode violations" because multiple elements on the page (sidebar nav items, page headings, card content) often contained the same text. - The Lesson: Relying solely on text content with
getByTextcan be brittle in rich UIs. - Actionable Takeaway:
- Scope your locators: Use
page.locator("main")or other parent elements to narrow the search context. - Be precise: Employ
{ exact: true }for case-sensitive matching when needed. - Use semantic roles:
getByRole("heading", { name: /Superadmin/i })is far more robust for headings thangetByText. - Introduce
data-testid: For elements that don't have clear semantic roles or unique text,data-testidattributes are your best friend. I addeddata-testid="tenant-switcher"to ourtenant-switcher.tsxcomponent precisely for this reason.
- Scope your locators: Use
2. Shadcn/Tailwind & Class Selectors
- The Problem: I attempted to select Shadcn Card components using
locator("[class*='CardContent']"). Shadcn components abstract away their styling, typically relying on Tailwind CSS utility classes. My selector, looking for a literal string like "CardContent" in the class attribute, matched nothing. - The Lesson: Don't rely on internal implementation details like generated CSS class names for your E2E tests, especially with UI libraries that use utility-first CSS frameworks like Tailwind.
- Actionable Takeaway: Prioritize semantic selectors (
getByRole,getByLabelText,getByPlaceholderText) or explicitdata-testidattributes. For example,page.locator("main").getByRole("button", { name: /Invite/i }).first()was a much more reliable way to find an invite button.
3. TypeScript typeof with Inline Arrays
- The Problem: I tried to define a function parameter type using
typeof [TENANT_A, TENANT_B]in TypeScript. This resulted in aTS1109error, astypeofdoesn't work directly on inline array expressions for type inference in this manner. - The Lesson:
typeofin TypeScript is used to get the type of an existing variable or property, not to infer types from arbitrary inline expressions in a type context directly. - Actionable Takeaway: When you need the type of an array of specific values, define the type explicitly or use
typeofon a variable that holds those values. For example,typeof TENANT_A[]or defining atype TenantArray = (typeof TENANT_A | typeof TENANT_B)[]would have been correct. I ended up simplifying by usingtypeof TENANT_A[]and managing manual defaults.
Wrapping Up
This session significantly leveled up our application's stability and our confidence in deploying changes. We now have a robust E2E test suite covering critical multi-tenant flows, and a battle-tested migration script to protect our pgvector data. The challenges encountered along the way provided valuable lessons that will undoubtedly make our future development and testing efforts more efficient.
Next up, we'll be committing these changes, verifying the db-migrate-safe.sh script against our local dev database, and then deploying these schema changes and new E2E tests to production. Onwards!
{"thingsDone":[
"Implemented E2E tests for superadmin and tenant-switching flows (72 passing tests)",
"Extended `tests/e2e/helpers/auth.ts` with `injectSuperAdminCookie()` and `injectTenantCookie()`",
"Created `tests/e2e/superadmin.spec.ts` covering tenant switcher, superadmin page, tenant/user/invitation management, and data isolation",
"Developed `scripts/db-migrate-safe.sh` for pgvector-safe database migrations using `prisma migrate diff` with auto-filtering of `DROP COLUMN` for `embedding` and `searchVector` columns",
"Added `data-testid='tenant-switcher'` for robust E2E testing",
"Removed unused `decodeTRPCInputs` helper"
],"pains":[
"Playwright `getByText` strict mode violations due to overlapping text content",
"Failed Playwright selectors for Shadcn components using `[class*='CardContent']` due to Tailwind's utility-class nature",
"TypeScript `TS1109` error using `typeof [TENANT_A, TENANT_B]` for inline array type inference"
],"successes":[
"Achieved 100% E2E test coverage for superadmin/tenant-switching flows",
"Created a reliable, review-first database migration script that protects sensitive vector columns",
"Improved Playwright locator strategies for multi-tenant applications",
"Gained deeper understanding of TypeScript `typeof` limitations with inline expressions"
],"techStack":[
"Playwright",
"TypeScript",
"Prisma",
"PostgreSQL",
"pgvector",
"Shadcn UI",
"Tailwind CSS",
"Shell Scripting"
]}