Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/app/payment-result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ProtectedRoute from '@/components/auth/ProtectedRoute';
import { PaymentStatusDisplay } from '@/components/payment/PaymentStatusDisplay/PaymentStatusDisplay';
import { OfflineRetryBanner } from '@/components/payment/OfflineRetryBanner';
import { featureFlags } from '@/config/payment';
import { getInternalUrl } from '@/config/project.config';
import { getPaymentStatus } from '@/lib/payments/payment-service';

type ResultState =
Expand Down Expand Up @@ -253,8 +254,12 @@ function PaymentResultContent() {
paymentResultId={state.resultId}
showDetails
onRetrySuccess={(newIntentId) => {
// Navigate to the new intent's result page
window.location.href = `/payment-result?id=${newIntentId}`;
// Navigate to the new intent's result page. Raw window.location does
// NOT get the basePath prefixed (unlike next/link), so wrap it in
// getInternalUrl or a project-site fork escapes to the domain root (#159).
window.location.href = getInternalUrl(
`/payment-result?id=${newIntentId}`
);
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,15 @@ describe('PaymentConsentModal', () => {
expect(acceptButton).toHaveFocus();
});
});

it('routes the privacy-policy link through next/link so it inherits the base path (#159)', () => {
render(<PaymentConsentModal />);

const link = screen.getByRole('link', { name: /read privacy policy/i });
// next/link renders an <a href="/privacy"> and prepends the runtime
// basePath in the real app (jsdom doesn't populate it). Was a raw <a>,
// which would have escaped to the domain root on a project-site fork.
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', '/privacy');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
'use client';

import React, { useEffect, useRef } from 'react';
import Link from 'next/link';
import { usePaymentConsent } from '@/hooks/usePaymentConsent';

export interface PaymentConsentModalProps {
Expand Down Expand Up @@ -192,13 +193,13 @@ export const PaymentConsentModal: React.FC<PaymentConsentModalProps> = ({
By accepting, you agree to our payment processing terms.
<br />
Read our{' '}
<a
<Link
href="/privacy"
className="link-hover link"
aria-label="Read privacy policy"
>
Privacy Policy
</a>{' '}
</Link>{' '}
for more details.
</p>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* PaymentStatusDisplay Component Tests
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PaymentStatusDisplay } from './PaymentStatusDisplay';
Expand Down Expand Up @@ -424,11 +424,24 @@ describe('PaymentStatusDisplay', () => {

describe('US3+US4 — switch provider + recovery disclosure (#43)', () => {
// Stub SwitchProviderPanel to a sentinel so we can assert wiring without
// re-mounting the whole sub-tree (it has its own dedicated tests).
// re-mounting the whole sub-tree (it has its own dedicated tests). The
// "fire switch success" button lets tests drive onSwitchSuccess so the
// parent's navigation (window.location.href) is exercised (#159).
vi.mock('@/components/payment/SwitchProviderPanel', () => ({
SwitchProviderPanel: (props: Record<string, unknown>) => (
<div data-testid="switch-provider-panel">
switch panel for {String(props.parentIntentId)}
<button
type="button"
data-testid="fire-switch-success"
onClick={() =>
(props.onSwitchSuccess as (id: string) => void)?.(
'new-intent-999'
)
}
>
fire switch success
</button>
</div>
),
}));
Expand Down Expand Up @@ -586,4 +599,85 @@ describe('PaymentStatusDisplay', () => {
expect(retryStep).toHaveClass('line-through');
});
});

describe('basePath-aware navigation (issue #159)', () => {
const originalEnv = process.env;

beforeEach(() => {
// Vitest doesn't load .env.local, but Docker injects .env into the
// container's process.env — so set the base path explicitly per case
// rather than relying on the ambient value.
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

function setupRecoverable() {
const failed = createMockResult('failed');
failed.error_code = 'card_declined';
vi.mocked(usePaymentRealtime).mockReturnValue({
paymentResult: failed,
loading: false,
error: null,
});
}

/** jsdom location stub that records href assignments instead of navigating. */
function stubLocation(): { href: string } {
const loc = { href: '' } as { href: string };
Object.defineProperty(window, 'location', {
value: loc,
writable: true,
configurable: true,
});
return loc;
}

async function openSwitchPanelAndFire() {
const user = userEvent.setup();
render(<PaymentStatusDisplay paymentResultId="test-id" />);
await user.click(
screen.getByRole('button', {
name: /use a different payment method/i,
})
);
await user.click(screen.getByTestId('fire-switch-success'));
}

it('prefixes the retry-success navigation with the base path', async () => {
process.env.NEXT_PUBLIC_BASE_PATH = '/ScriptHammer';
setupRecoverable();
const loc = stubLocation();
await openSwitchPanelAndFire();
expect(loc.href).toBe('/ScriptHammer/payment-result?id=new-intent-999');
});

it('navigates to the unprefixed path when no base path is set', async () => {
delete process.env.NEXT_PUBLIC_BASE_PATH;
setupRecoverable();
const loc = stubLocation();
await openSwitchPanelAndFire();
expect(loc.href).toBe('/payment-result?id=new-intent-999');
});

it('routes the support link through next/link so it inherits the base path', () => {
const failed = createMockResult('failed');
failed.error_code = 'expired_card'; // non-recoverable → button-style link
vi.mocked(usePaymentRealtime).mockReturnValue({
paymentResult: failed,
loading: false,
error: null,
});
render(<PaymentStatusDisplay paymentResultId="test-id" />);
const link = screen.getByRole('link', { name: /contact support/i });
// next/link renders an <a href="/contact">; it prepends the runtime
// basePath (__NEXT_ROUTER_BASEPATH) in the real app, which jsdom doesn't
// populate — so we assert the anchor points at the app-relative route.
// The prefixed form is pinned end-to-end by the basePath E2E project (#157).
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', '/contact');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
'use client';

import React from 'react';
import Link from 'next/link';
import { usePaymentRealtime } from '@/hooks/usePaymentRealtime';
import { usePaymentRetryStatus } from '@/hooks/usePaymentRetryStatus';
import {
Expand All @@ -17,6 +18,7 @@ import {
} from '@/lib/payments/payment-service';
import { categorizePaymentError } from '@/lib/payments/error-categorization';
import { SwitchProviderPanel } from '@/components/payment/SwitchProviderPanel';
import { getInternalUrl } from '@/config/project.config';
import type { Currency } from '@/types/payment';

export interface PaymentStatusDisplayProps {
Expand Down Expand Up @@ -433,7 +435,11 @@ export const PaymentStatusDisplay: React.FC<PaymentStatusDisplayProps> = ({
<SwitchProviderPanel
parentIntentId={paymentResult.intent_id}
onSwitchSuccess={(newIntentId) => {
window.location.href = `/payment-result?id=${newIntentId}`;
// Raw window.location isn't basePath-prefixed; wrap
// it so a project-site fork stays in-app (#159).
window.location.href = getInternalUrl(
`/payment-result?id=${newIntentId}`
);
}}
/>
</div>
Expand Down Expand Up @@ -474,9 +480,9 @@ export const PaymentStatusDisplay: React.FC<PaymentStatusDisplayProps> = ({
: ''
}
>
<a href="/contact" className="link">
<Link href="/contact" className="link">
Contact support
</a>{' '}
</Link>{' '}
with the transaction reference above.
</li>
</ol>
Expand All @@ -485,13 +491,13 @@ export const PaymentStatusDisplay: React.FC<PaymentStatusDisplayProps> = ({
</>
) : (
<div className="card-actions mt-4 justify-end">
<a
<Link
href="/contact"
className="btn btn-outline min-h-11"
aria-label="Contact support about this payment"
>
Contact Support
</a>
</Link>
</div>
)}
</>
Expand Down
Loading