Shared Components — Wave 3 Design
Date: 2026-04-16 Author: Dagan Gilat + Claude Status: Implemented Depends on: Wave 1 (firebase-core, auth, payments, ai, messaging) + Wave 2 (db, i18n, legal, flows, seeders) — all complete
Context
Waves 1 and 2 delivered 10 shared packages integrated across 4 projects. Wave 3 addresses three critical gaps:
- No shared test infrastructure — Foundation has tests (Vitest + Playwright), other projects have zero. Shared packages have no tests at all.
- No E2E flow testing — Foundation has 10 Playwright specs but they're project-specific. No reusable patterns, no flow-driven testing, no cross-project test runner.
- No account deletion — Zero projects can delete a user account. GDPR non-compliant. User data scattered across 7-17 collections per project with no cascade logic.
Package Overview
| # | Package | Pattern | Primary consumers |
|---|---|---|---|
| 1 | @plantagoai/testing |
Vitest helpers, Firebase emulator setup, mock factories, package test harness | All 10 shared packages, all projects |
| 2 | @plantagoai/e2e |
Playwright helpers, flow-driven test generation, screenshot regression | Foundation (extend), MarketHub, HerbPulse |
| 3 | @plantagoai/auth (extend) |
Account deletion orchestrator, data export, GDPR compliance | All projects |
Dependency Order
@plantagoai/testing <- build first (packages need it for their own tests)
@plantagoai/e2e <- depends on testing + flows (flow-driven test generation)
@plantagoai/auth ext <- depends on db (cascade deletion schema)
1. @plantagoai/testing — Unit & Integration Test Framework
Current State
| Project | Unit Tests | Framework | Package Tests |
|---|---|---|---|
| Foundation frontend | Vitest configured, few tests | Vitest 4.1.0 | — |
| Foundation backend | Mocha/Chai configured, no scripts | Mocha 9.0.3 | — |
| Foundation (retired Rust gateway) | 5 integration tests, retired 2026-04-19 | — | — |
| MarketHub | None | — | — |
| HerbPulse | None | — | — |
| Soho | Jest configured, no tests | Jest | — |
| Shared packages (x10) | None | — | None |
Design
Standardize on Vitest across all TypeScript projects (fast, ESM-native, Vite-compatible). Provide test helpers that eliminate boilerplate for Firebase-backed code.
@plantagoai/testing
├── src/
│ ├── index.ts # setup(), createTestDb(), createTestAuth()
│ ├── emulators.ts # Firebase emulator lifecycle (start/stop/reset)
│ ├── firestore.ts # seedTestData(), clearCollection(), assertDoc()
│ ├── auth.ts # createTestUser(), signInAsRing(), mockClaims()
│ ├── mocks/
│ │ ├── ai.ts # mockClaude(), mockGemini() — deterministic responses
│ │ ├── payments.ts # mockGateway(), mockDropIn() — fake Braintree
│ │ ├── messaging.ts # mockMailer(), capturePush(), captureNotification()
│ │ └── http.ts # mockFetch(), mockApi() — HTTP request mocking
│ ├── assertions.ts # Custom matchers: toMatchSchema(), toHaveRing(), toBeValidDoc()
│ └── runner.ts # runPackageTests() — shared vitest config for packages
API
import { setup, createTestDb, createTestAuth } from "@plantagoai/testing";
import { createTestUser, signInAsRing } from "@plantagoai/testing";
import { seedTestData, assertDoc, clearCollection } from "@plantagoai/testing";
// Setup — connects to Firebase emulators
const { db, auth } = await setup({ projectId: "test-project" });
// Create test users with specific rings
const admin = await createTestUser(auth, {
email: "admin@test.com",
ring: Ring.TENANT_ADMIN,
role: "admin",
tenantId: "test-org",
});
const user = await createTestUser(auth, {
email: "voter@test.com",
ring: Ring.USER,
role: "user",
});
// Seed test data
await seedTestData(db, "proposals", [
{ title: "Test Proposal", status: "active", options: [...] },
]);
// Assert document state
await assertDoc(db, "proposals", "p1", {
status: "active",
total_votes: 0,
});
// Custom Vitest matchers
expect(proposal).toMatchSchema(proposalSchema);
expect(claims).toHaveRing(Ring.TENANT_ADMIN);
// Cleanup
await clearCollection(db, "proposals");
Mock Factories
import { mockClaude, mockGemini } from "@plantagoai/testing/mocks";
import { mockGateway } from "@plantagoai/testing/mocks";
// AI — returns deterministic responses
const claude = mockClaude({
responses: { "Review this proposal": "The proposal aligns with principles 1, 3, 5." },
defaultTokens: { input: 100, output: 50, cacheRead: 80 },
});
// Payments — fake gateway that always succeeds/fails
const gateway = mockGateway({ mode: "success" }); // or "failure" or "timeout"
const token = await getClientToken(gateway, "test-customer");
// Messaging — capture sent messages
const { mailer, sent } = mockMailer();
await sendEmail({ to: "user@test.com", subject: "Test" });
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe("user@test.com");
Package Test Runner
Shared Vitest config so every package runs tests the same way:
// In each package's vitest.config.ts
import { packageTestConfig } from "@plantagoai/testing/runner";
export default packageTestConfig({
emulators: true, // start Firebase emulators before tests
coverage: true, // collect coverage
});
Each package adds to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@plantagoai/testing": "file:../testing",
"vitest": "^4.1.0"
}
}
What Gets Tested
| Package | Test Focus |
|---|---|
firebase-core |
CRUD operations, tenant scoping, batch writes, env detection |
auth |
Ring checks, role definitions, middleware (requireRing, requireAuth), claims |
payments |
Client token generation, payment processing, subscription lifecycle |
ai |
Claude/Gemini wrappers (mocked SDKs), prompt caching flags, usage tracking |
messaging |
Email send, push send, notification CRUD, unread count |
db |
Schema validation (all 3 phases), auto-fix, export, rules generation |
i18n |
Init, language switching, RTL, content translation caching, audit |
legal |
Document generation (all section combos), acceptance record/check |
flows |
Machine creation, state transitions, persistence save/load, presets |
seeders |
JSON/CSV/API loading, Zod validation, batch write, idempotency, cleanup |
2. @plantagoai/e2e — End-to-End Test Framework
Current State
| Project | E2E Tests | Framework | Specs |
|---|---|---|---|
| Foundation (Python) | 5 Pytest + Playwright tests | Pytest | smoke, auth, proposal, PoH vote, visual |
| Foundation (TS) | 10 Playwright specs | @playwright/test | navigation, auth, governance, admin, marketplace |
| MarketHub | None | — | — |
| HerbPulse | None | — | — |
| Soho | None | — | — |
Design
Standardize on Playwright (TypeScript) across all projects. Add two differentiating capabilities:
- Flow-driven test generation — use
@plantagoai/flowsstate machine definitions to auto-generate test paths - Screenshot regression — capture and compare screenshots at each flow state
@plantagoai/e2e
├── src/
│ ├── index.ts # createE2E(), defineTestSuite()
│ ├── config.ts # Shared Playwright config factory
│ ├── flow-runner.ts # flowTest() — walk a FlowDef, test each transition
│ ├── screenshots.ts # captureState(), compareBaseline(), updateBaseline()
│ ├── auth-helpers.ts # loginAs(), loginAsAdmin(), loginWithGoogle()
│ ├── firebase-helpers.ts # waitForFirestore(), seedBeforeTest(), cleanAfterTest()
│ ├── assertions.ts # expectPage(), expectToast(), expectNavigation()
│ └── reporters/
│ ├── html.ts # Enhanced HTML report with screenshots
│ └── slack.ts # Post test results to Slack channel
Flow-Driven Testing
The key innovation: take a FlowDef from @plantagoai/flows and automatically generate test cases that walk every path through the state machine.
import { flowTest } from "@plantagoai/e2e";
import { checkoutFlow } from "@plantagoai/flows";
// Auto-generates tests for every reachable path:
// cart → shipping → payment → processing → confirmed
// cart → shipping → payment → processing → failed → payment (retry)
// cart → shipping → back → cart
// etc.
flowTest(checkoutFlow(), {
baseUrl: "http://localhost:5173",
// Map each state to a page action
stateActions: {
cart: async (page) => { await page.click('[data-testid="add-item"]'); },
shipping: async (page) => { await page.fill('#address', '123 Main St'); },
payment: async (page) => { await page.fill('#card', '4111111111111111'); },
confirmed: async (page) => { await page.waitForSelector('.confirmation'); },
},
// Map each event to a UI interaction
eventActions: {
PROCEED: async (page) => { await page.click('[data-testid="proceed"]'); },
CONFIRM_ADDRESS: async (page) => { await page.click('[data-testid="confirm-address"]'); },
PAY: async (page) => { await page.click('[data-testid="pay"]'); },
BACK: async (page) => { await page.click('[data-testid="back"]'); },
},
// Screenshot at each state
screenshots: true,
});
This generates Playwright test cases that:
- Enumerate all paths through the state machine (BFS/DFS with configurable depth)
- Execute the mapped UI actions for each transition
- Assert the page reaches the expected state
- Capture screenshots at each state for visual regression
- Report which paths pass/fail with state-by-state detail
Screenshot Regression
import { captureState, compareBaseline } from "@plantagoai/e2e";
test("proposal page visual regression", async ({ page }) => {
await page.goto("/proposals/123");
// Capture and compare
const result = await captureState(page, "proposal-detail", {
fullPage: true,
mask: [".timestamp", ".random-id"], // mask dynamic content
});
// First run: saves baseline. Subsequent runs: pixel comparison
await compareBaseline(result, { threshold: 0.01 }); // 1% pixel diff tolerance
});
Baselines stored in e2e/baselines/ per project. CI updates baselines on main branch merges.
Auth Helpers
import { loginAs, loginAsAdmin } from "@plantagoai/e2e";
test("admin can access validator", async ({ page }) => {
await loginAsAdmin(page); // Uses test admin credentials
await page.goto("/admin/testing");
await expect(page.locator("h1")).toHaveText("Admin Panel");
});
test("user sees governance", async ({ page }) => {
await loginAs(page, { email: "voter@test.com", password: "test123" });
await page.goto("/governance");
});
Shared Playwright Config
// In each project's playwright.config.ts
import { createPlaywrightConfig } from "@plantagoai/e2e";
export default createPlaywrightConfig({
baseUrl: "http://localhost:5173",
projects: ["chromium", "firefox", "webkit"], // or just "chromium"
screenshotOnFailure: true,
htmlReport: true,
slackWebhook: process.env.SLACK_E2E_WEBHOOK, // optional
});
Test Suites Per Project
| Project | Flows to Test | Key Paths |
|---|---|---|
| Foundation | Governance (support → vote → pass), Onboarding, PoH verification | Proposal lifecycle, admin tools, 3 pillars |
| MarketHub | Checkout, Vendor onboarding, Product listing | Browse → cart → pay → confirm, vendor dashboard |
| HerbPulse | Onboarding, Language switch, Herb search | Multi-language navigation, search, RTL toggle |
3. @plantagoai/auth Extension — Account Deletion & GDPR
Current State
| Project | Delete Account | Data Export | GDPR | User Data Collections |
|---|---|---|---|---|
| Foundation | No | No | No | 7+ (voters, votes, identity_proofs, signatures, ...) |
| MarketHub | No | No | No | 17+ (users subcollections, orders, subscriptions, payments, ...) |
| Soho | No | No | No | 8+ (contacts, campaigns, conversations, ...) |
| HerbPulse | No | No | No | Minimal |
Design
Extend @plantagoai/auth with account lifecycle functions. Each project defines its user data map — which collections hold user data and how they're linked. The package handles orchestration, cascade deletion, audit logging, and Firebase Auth cleanup.
New files added to @plantagoai/auth:
@plantagoai/auth
├── src/
│ ├── ... (existing files)
│ ├── account-deletion.ts # deleteAccount(), requestDeletion(), cancelDeletion()
│ ├── data-export.ts # exportUserData() — GDPR data portability
│ └── user-data-map.ts # defineUserDataMap() — per-project data schema
User Data Map
Each project defines where user data lives:
import { defineUserDataMap } from "@plantagoai/auth";
// Foundation
const foundationDataMap = defineUserDataMap({
collections: [
{ name: "voters", userField: "uid", mode: "delete" },
{ name: "votes", userField: "voter_uid", mode: "anonymize",
anonymize: ["voter_uid"] }, // keep vote, remove user link
{ name: "supporter_signatures", userField: "voter_uid", mode: "anonymize",
anonymize: ["voter_uid"] },
{ name: "identity_proofs", userField: "voter_id", mode: "delete" },
{ name: "manual_review_requests", userField: "uid", mode: "delete" },
{ name: "tenant_memberships", userField: "uid", mode: "delete" },
],
notifications: { collection: "notifications", userField: "userId", mode: "delete" },
legalConsents: { collection: "legal_consents", userField: "userId", mode: "retain" },
});
// MarketHub
const markethubDataMap = defineUserDataMap({
collections: [
{ name: "users", userField: "__docId__", mode: "delete",
subcollections: ["cart", "wishlist", "orders", "addresses", "preferences"] },
{ name: "orders", userField: "user_id", mode: "anonymize",
anonymize: ["user_id", "email", "name", "shipping_address", "billing_address", "phone"],
retainYears: 10 }, // Tax/VAT retention
{ name: "subscriptions", userField: "userId", mode: "anonymize",
anonymize: ["userId", "email"],
retainYears: 10 },
{ name: "savedPaymentMethods", userField: "user_id", mode: "delete" },
{ name: "usageTracking", userField: "vendor_email", mode: "delete" },
{ name: "transactions", userField: "vendor_email", mode: "anonymize",
anonymize: ["vendor_email", "vendor_name"],
retainYears: 10 },
{ name: "commissions", userField: "vendor_id", mode: "anonymize",
anonymize: ["vendor_id", "vendor_name"],
retainYears: 10 },
],
external: [
{ provider: "braintree", action: "customer.delete", idField: "braintree_customer_id" },
],
notifications: { collection: "notifications", userField: "userId", mode: "delete" },
legalConsents: { collection: "legal_acceptances", userField: "__docId__", mode: "retain" },
});
Deletion Modes
| Mode | Behavior | Use When |
|---|---|---|
delete |
Remove document entirely | Private data, no audit need (cart, preferences, proofs, identity docs) |
anonymize |
Replace PII fields with "[deleted]", keep document |
Financial records with legal retention (orders, transactions, commissions) and aggregate data (votes) |
retain |
Keep document unchanged | Legal/compliance records (consent history, audit logs) |
external |
Call external API to delete | Braintree vault (customer.delete()), other third-party services |
API
import { deleteAccount, requestDeletion, cancelDeletion, exportUserData } from "@plantagoai/auth";
// === Immediate deletion (admin or self-service) ===
const result = await deleteAccount(userId, foundationDataMap, {
deleteFirebaseAuth: true, // also delete Firebase Auth record
cancelSubscriptions: true, // cancel active subscriptions via @plantagoai/payments
reason: "user_requested", // audit trail
dryRun: false, // true = report only
});
// {
// userId: "user-123",
// deletedDocs: 15,
// anonymizedDocs: 42,
// retainedDocs: 2,
// authDeleted: true,
// collections: { voters: 1, votes: 12, identity_proofs: 2, ... },
// completedAt: "2026-04-16T12:00:00Z"
// }
// === Deferred deletion (30-day grace period) ===
await requestDeletion(userId, {
gracePeriodDays: 30,
notifyEmail: "user@example.com", // sends confirmation email via @plantagoai/messaging
});
// User changes their mind
await cancelDeletion(userId);
// === Data export (GDPR Article 20 — right to data portability) ===
const data = await exportUserData(userId, foundationDataMap);
// {
// user: { email: "...", ... },
// voters: [ { wallet_address: "...", ... } ],
// votes: [ { proposal_id: "...", ... } ],
// identity_proofs: [ ... ],
// exportedAt: "2026-04-16T12:00:00Z",
// format: "json"
// }
Cloud Function Per Project
// Foundation: functions/index.js
import { deleteAccount, exportUserData } from "@plantagoai/auth";
import { requireAuth } from "@plantagoai/auth/middleware";
export const deleteMyAccount = onCall(async (request) => {
const { uid } = await requireAuth(request);
return deleteAccount(uid, foundationDataMap, { deleteFirebaseAuth: true });
});
export const exportMyData = onCall(async (request) => {
const { uid } = await requireAuth(request);
return exportUserData(uid, foundationDataMap);
});
Audit Trail
Every deletion is logged to a deletion_audit collection:
{
userId: "user-123",
requestedAt: "2026-04-16T10:00:00Z",
completedAt: "2026-04-16T10:00:05Z",
reason: "user_requested",
initiatedBy: "user-123", // or admin uid
deletedCollections: ["voters", "identity_proofs"],
anonymizedCollections: ["votes", "supporter_signatures"],
retainedCollections: ["legal_consents"],
docCounts: { deleted: 15, anonymized: 42, retained: 2 },
authDeleted: true
}
Workspace Structure (Updated)
/Users/dagan/dev/shared/
packages/
# Wave 1 (complete)
firebase-core/
auth/ # extended in Wave 3 with account deletion
payments/
ai/
messaging/
# Wave 2 (complete)
db/
i18n/
legal/
flows/
seeders/
# Wave 3 (this document)
testing/ # NEW: Vitest helpers, mocks, emulator management
e2e/ # NEW: Playwright helpers, flow-driven testing
Implementation Order
Phase 1 — @plantagoai/testing
- Create package with Vitest config, emulator lifecycle, Firestore/Auth helpers
- Create mock factories for AI, payments, messaging
- Add
testscript and first tests to all 10 existing packages - Goal: every shared package has unit/integration tests
Phase 2 — @plantagoai/e2e
- Create package with Playwright config factory, auth helpers, screenshot regression
- Build flow-runner that walks
FlowDefstate machines - Migrate Foundation's existing Playwright specs to use shared helpers
- Add E2E suites for MarketHub checkout and HerbPulse onboarding
Phase 3 — @plantagoai/auth account deletion
- Add
defineUserDataMap(),deleteAccount(),exportUserData()to auth package - Define data maps for Foundation, MarketHub, Soho
- Add
requestDeletion()/cancelDeletion()with grace period - Create Cloud Function endpoints per project
- Add deletion audit logging
Decisions (Resolved)
- Python E2E tests — Migrate Foundation's 5 Pytest+Playwright specs to TypeScript Playwright. TS only across all projects.
- CI integration — Out of scope. CI YAML files are trivial (
npm test) and project-specific. Not part of the package. - Deletion grace period — 90 days default, configurable per project via
gracePeriodDays. - Anonymization depth — Scrub all PII fields (user_id, email, name, addresses, phone) but keep order structure (items, totals, dates, status, payment type) for business reporting and tax compliance.
- Financial records & Braintree —
deleteAccount()must call Braintreecustomer.delete()to remove the vault (payment methods, saved cards). However, transaction/order records must be retained for 6-10 years (tax/VAT and AML legal obligations override GDPR right to erasure per Article 17(3)(b)). These records are anonymized (PII scrubbed) but kept with financial data intact.
Financial Data Retention Rules
| Data Type | On Account Deletion | Legal Retention |
|---|---|---|
| User profile (name, email, address) | Anonymize immediately | — |
| Payment methods (Braintree vault) | Delete via API immediately | — |
| Order records | Anonymize PII, keep items/totals/dates/tax | 6-10 years (tax/VAT) |
| Transaction/commission records | Anonymize PII, keep amounts | 6-10 years (tax/VAT) |
| Subscription billing history | Cancel active, anonymize PII, keep history | 6-10 years (tax/VAT) |
| Legal consents (ToS acceptance) | Retain unchanged | Indefinite (compliance proof) |
Sources: GDPR Article 17(3)(b), EU VAT Directive retention requirements, AML 5th Directive.