Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import { listFailedPairings, ensureAtLeastOneFailedPairing } from './helpers/backend-api.js'
/**
* Failed Pairings page e2e tests.
*
* NOTE: There is currently no public test-seed endpoint for failed_pairings.
* Production rows are inserted by the backend pairing service when a real
* pairing fails. These tests therefore rely on the database already containing
* at least one row (any cause). If the table is empty the tests are skipped
* with a clear TODO so the user can either:
* - run the real pairing flow once to generate real fixtures, or
* - add a backend test-only seed route (see helpers/backend-api.js).
*/
test.describe('Failed Pairings page', () => {
test.beforeEach(async ({ page }) => {
await loginAsOperator(page)
})
test('renders the table when at least one row exists', async ({ page }) => {
let seeded
try {
seeded = await ensureAtLeastOneFailedPairing()
} catch (e) {
test.skip(true, `No failed-pairings rows in DB. ${e.message}`)
return
}
await page.goto('/failed-pairings')
await expect(page.getByRole('heading', { name: 'Failed Pairings', level: 1 })).toBeVisible()
// The table headers should always render once data has loaded.
await expect(page.getByRole('columnheader', { name: 'Created' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Cause' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Operator Action' })).toBeVisible()
// At least one data row (skip the header row).
const dataRows = page.locator('table tbody tr')
const count = await dataRows.count()
expect(count).toBeGreaterThanOrEqual(1)
// The "empty" placeholder row should NOT be visible when we have data.
await expect(page.getByText('Belum ada data failed pairings.')).not.toBeVisible()
// Sanity check — total label matches what the backend reports.
await expect(page.getByText(`(${seeded.total} total)`)).toBeVisible()
})
test('cause-tag filter narrows the visible rows', async ({ page }) => {
// We need rows of at least 2 distinct cause tags to make a meaningful
// assertion about filtering. If the DB has fewer, skip with a clear
// message rather than producing a fake-positive.
const all = await listFailedPairings({ limit: 200 })
const distinctCauses = [...new Set((all?.rows ?? []).map((r) => r.cause_tag))]
if (distinctCauses.length < 2) {
test.skip(
true,
`Need >=2 distinct cause_tags in failed_pairings to test filter. Found: ${distinctCauses.join(', ') || '(none)'}.`,
)
return
}
const targetCause = distinctCauses[0]
const expectedFiltered = await listFailedPairings({ causeTags: [targetCause], limit: 200 })
await page.goto('/failed-pairings')
await expect(page.getByRole('heading', { name: 'Failed Pairings', level: 1 })).toBeVisible()
// Capture pre-filter total — must be >= filtered total or assertion is bogus.
const totalBefore = all.total
expect(totalBefore).toBeGreaterThan(expectedFiltered.total)
// Tick the cause checkbox. The filter section labels the checkbox with the
// human label from PairingFailureCauseLabel — find it by its associated text.
// Map the cause_tag to its label by re-importing the constants.
const { PairingFailureCauseLabel } = await import(
'../../src/core/constants.js'
)
const targetLabel = PairingFailureCauseLabel[targetCause]
await page.getByRole('checkbox', { name: targetLabel }).check()
// Expect the new total to match the API-computed expectation.
await expect(page.getByText(`(${expectedFiltered.total} total)`)).toBeVisible()
// And every visible row should reflect the chosen cause tag.
const causeCells = page.locator('table tbody tr td:nth-child(4)')
const visibleCount = await causeCells.count()
expect(visibleCount).toBeGreaterThan(0)
for (let i = 0; i < visibleCount; i++) {
await expect(causeCells.nth(i)).toHaveText(targetLabel)
}
})
})