diff --git a/.github/workflows/playwright-e2e.yaml b/.github/workflows/playwright-e2e.yaml
index 763ce88b..f48e7f9d 100644
--- a/.github/workflows/playwright-e2e.yaml
+++ b/.github/workflows/playwright-e2e.yaml
@@ -11,9 +11,70 @@ on:
jobs:
e2e:
- name: E2E Tests
+ name: Playwright E2E Tests (${{ matrix.state }})
runs-on: ubuntu-latest
timeout-minutes: 15
+ strategy:
+ matrix:
+ state: [dc, co]
+ fail-fast: false
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: "10"
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --prefer-offline
+
+ - name: Install Playwright browsers
+ run: cd src/SEBT.Portal.Web && pnpm exec playwright install --with-deps chromium
+
+ - name: Build frontend for E2E
+ env:
+ STATE: ${{ matrix.state }}
+ NEXT_PUBLIC_STATE: ${{ matrix.state }}
+ run: ./.github/workflows/scripts/build-frontend.sh --production
+
+ - name: Run Playwright E2E tests
+ env:
+ CI: true
+ SKIP_WEB_SERVER: "1"
+ STATE: ${{ matrix.state }}
+ NEXT_PUBLIC_STATE: ${{ matrix.state }}
+ run: |
+ (cd src/SEBT.Portal.Web && pnpm start) &
+ echo "Waiting for server at http://localhost:3000..."
+ for i in $(seq 1 90); do
+ curl -sf --max-time 15 http://localhost:3000 > /dev/null && echo "Server ready" && break
+ sleep 2
+ done
+ curl -sf --max-time 15 http://localhost:3000 > /dev/null || (echo "Server failed to start" && exit 1)
+ echo "Running Playwright E2E tests..."
+ pnpm ci:test:e2e
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: ${{ always() }}
+ with:
+ name: playwright-report-${{ matrix.state }}
+ path: src/SEBT.Portal.Web/playwright-report/
+ retention-days: 7
+
+ a11y:
+ name: Pa11y Accessibility Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
services:
mssql:
@@ -75,26 +136,22 @@ jobs:
- name: Build backend
run: ./.github/workflows/scripts/build-backend.sh --configuration Release
- - name: Install Playwright browsers
- run: cd src/SEBT.Portal.Web && pnpm exec playwright install --with-deps chromium
-
- name: Install Chrome for Pa11y
run: cd src/SEBT.Portal.Web && pnpm exec puppeteer browsers install chrome
- - name: Build frontend for E2E
+ - name: Build frontend for Pa11y
env:
STATE: dc
NEXT_PUBLIC_STATE: dc
run: ./.github/workflows/scripts/build-frontend.sh --production
- - name: Run Pa11y and Playwright E2E tests
+ - name: Run Pa11y accessibility tests
env:
CI: true
SKIP_WEB_SERVER: "1"
STATE: dc
NEXT_PUBLIC_STATE: dc
ASPNETCORE_ENVIRONMENT: Development
- # Required at startup (AddPlugins); paths may be absent β MEF skips missing dirs and defaults apply.
ConnectionStrings__DefaultConnection: "Server=localhost,1433;Database=SebtPortal;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
JwtSettings__SecretKey: "ci-e2e-jwt-secret-at-least-32-characters-long"
IdentifierHasher__SecretKey: "ci-e2e-identifier-hasher-key-32chars"
@@ -102,8 +159,7 @@ jobs:
PluginAssemblyPaths__0: "plugins-dc"
UseMockHouseholdData: "true"
run: |
- # Start API in dev mode + frontend from production build
- pnpm api:dev &
+ ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/SEBT.Portal.Api --launch-profile http --configuration Release --no-build &
(cd src/SEBT.Portal.Web && pnpm start) &
echo "Waiting for server at http://localhost:3000..."
for i in $(seq 1 90); do
@@ -113,13 +169,3 @@ jobs:
curl -sf --max-time 15 http://localhost:3000 > /dev/null || (echo "Server failed to start" && exit 1)
echo "Running Pa11y accessibility tests..."
pnpm ci:test:a11y
- echo "Running Playwright E2E tests..."
- pnpm ci:test:e2e
-
- - name: Upload Playwright report
- uses: actions/upload-artifact@v4
- if: ${{ !cancelled() }}
- with:
- name: playwright-report
- path: src/SEBT.Portal.Web/playwright-report/
- retention-days: 7
diff --git a/packages/design-system/content/scripts/generate-locales.js b/packages/design-system/content/scripts/generate-locales.js
index 6a7de37a..a46a6d9d 100644
--- a/packages/design-system/content/scripts/generate-locales.js
+++ b/packages/design-system/content/scripts/generate-locales.js
@@ -301,10 +301,11 @@ function parseContentKey(contentKey) {
function buildStateLocaleData(rows, state) {
const [headerRow, ...dataRows] = rows;
- // Find column indices (handle emoji prefixes like "π‘ Content")
- const contentIdx = headerRow.findIndex((h) =>
- h.toLowerCase().includes('content')
- );
+ // Find column indices (handle emoji prefixes like "π‘ Content" and renamed headers like "Variable Name/Key")
+ const contentIdx = headerRow.findIndex((h) => {
+ const lower = h.toLowerCase()
+ return lower.includes('content') || lower.includes('variable name')
+ });
const englishIdx = headerRow.findIndex((h) =>
h.toLowerCase().includes('english')
);
diff --git a/packages/design-system/content/scripts/generate-locales.test.js b/packages/design-system/content/scripts/generate-locales.test.js
index 695dcacc..edd6a419 100644
--- a/packages/design-system/content/scripts/generate-locales.test.js
+++ b/packages/design-system/content/scripts/generate-locales.test.js
@@ -65,5 +65,28 @@ assert.ok(!enrollmentContent.includes('dashboard'), 'enrollment barrel must NOT
// Test: locale JSON was written to --out-dir (not to script's own directory)
assert.ok(existsSync(join(tmpDir, 'locales', 'en', 'co', 'landing.json')), 'locale JSON must be written to --out-dir')
+// Test: new CSV column headers ("Variable Name/Key", "π’ CO English", "π’ CO EspaΓ±ol")
+setup()
+const csvNewHeaders = [
+ 'Variable Name/Key,π’ CO English,βͺ SOURCE English,π’ CO EspaΓ±ol,βͺ SOURCE EspaΓ±ol,Notes',
+ 'GLOBAL - Button Continue,Continue,,Continuar,,',
+ 'S1 - Landing Page - Title,New Header Landing,,New Header Landing ES,,',
+].join('\n')
+writeFileSync(join(tmpDir, 'states', 'co.csv'), csvNewHeaders)
+
+execFileSync('node', [
+ script,
+ '--csv-dir', join(tmpDir, 'states'),
+ '--out-dir', join(tmpDir, 'locales'),
+ '--ts-out', join(tmpDir, 'ts-out', 'new-header-resources.ts'),
+ '--app', 'portal',
+], { stdio: 'inherit' })
+
+const newHeaderLanding = JSON.parse(readFileSync(join(tmpDir, 'locales', 'en', 'co', 'landing.json'), 'utf8'))
+assert.equal(newHeaderLanding.title, 'New Header Landing', 'must parse content from "Variable Name/Key" column header')
+
+const newHeaderCommon = JSON.parse(readFileSync(join(tmpDir, 'locales', 'es', 'co', 'common.json'), 'utf8'))
+assert.equal(newHeaderCommon.buttonContinue, 'Continuar', 'must parse Spanish from state-prefixed EspaΓ±ol column')
+
console.log('β All generate-locales CLI arg tests passed')
teardown()
diff --git a/src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs b/src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs
index e44a8f7b..977a16b9 100644
--- a/src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs
+++ b/src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs
@@ -118,11 +118,15 @@ public async Task CompleteLogin(
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
var validationParams = new TokenValidationParameters
{
+ ValidateIssuerSigningKey = true,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
- IssuerSigningKey = key
+ // Use resolver instead of IssuerSigningKey to bypass kid-matching;
+ // jose (Next.js) signs without a kid header, which causes IDX10517
+ // when JwtSecurityTokenHandler tries to match by kid.
+ IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => [key]
};
var handler = new JwtSecurityTokenHandler();
handler.MapInboundClaims = false; // Preserve original JWT claim names (sub, email)
diff --git a/src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs b/src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs
index e41a6731..a75cb7a1 100644
--- a/src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs
+++ b/src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs
@@ -85,4 +85,36 @@ public async Task UpdateAddress(
var result = await commandHandler.Handle(command, cancellationToken);
return result.ToActionResult();
}
+
+ ///
+ /// Requests replacement cards for the authenticated user's household.
+ ///
+ /// The application numbers to request replacements for.
+ /// The use case handler for requesting card replacements.
+ /// A token to monitor for cancellation requests.
+ /// No content on success; otherwise, BadRequest, Forbidden, or NotFound.
+ /// Card replacement request recorded successfully.
+ /// Validation failed (no applications selected or cooldown active).
+ /// User is not authorized or no household identifier could be resolved from token.
+ /// Household data not found for the authenticated user.
+ [HttpPost("cards/replace")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ public async Task RequestCardReplacement(
+ [FromBody] RequestCardReplacementRequest request,
+ [FromServices] ICommandHandler commandHandler,
+ CancellationToken cancellationToken = default)
+ {
+ var command = new RequestCardReplacementCommand
+ {
+ User = User,
+ ApplicationNumbers = request.ApplicationNumbers
+ };
+
+ var result = await commandHandler.Handle(command, cancellationToken);
+ return result.ToActionResult();
+ }
}
diff --git a/src/SEBT.Portal.Api/Models/Household/RequestCardReplacementRequest.cs b/src/SEBT.Portal.Api/Models/Household/RequestCardReplacementRequest.cs
new file mode 100644
index 00000000..a2f0f262
--- /dev/null
+++ b/src/SEBT.Portal.Api/Models/Household/RequestCardReplacementRequest.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace SEBT.Portal.Api.Models.Household;
+
+///
+/// Request model for requesting replacement cards for one or more applications.
+///
+public record RequestCardReplacementRequest
+{
+ /// Application numbers identifying which cards to replace.
+ [Required(ErrorMessage = "At least one application number is required.")]
+ [MinLength(1, ErrorMessage = "At least one application number is required.")]
+ public required List ApplicationNumbers { get; init; }
+}
diff --git a/src/SEBT.Portal.Kernel/Results/DependencyFailedReason.cs b/src/SEBT.Portal.Kernel/Results/DependencyFailedReason.cs
index 0fb5f959..f11028ed 100644
--- a/src/SEBT.Portal.Kernel/Results/DependencyFailedReason.cs
+++ b/src/SEBT.Portal.Kernel/Results/DependencyFailedReason.cs
@@ -4,5 +4,8 @@ public enum DependencyFailedReason
{
ConnectionFailed,
Timeout,
+ Authentication,
+ BadRequest,
+ ServiceUnavailable,
NotConfigured
}
diff --git a/src/SEBT.Portal.TestUtilities/Helpers/HouseholdFactory.cs b/src/SEBT.Portal.TestUtilities/Helpers/HouseholdFactory.cs
index bc96c38d..4d6f20c3 100644
--- a/src/SEBT.Portal.TestUtilities/Helpers/HouseholdFactory.cs
+++ b/src/SEBT.Portal.TestUtilities/Helpers/HouseholdFactory.cs
@@ -120,7 +120,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
if (status == ApplicationStatus.Approved)
{
- application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
+ application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
application.CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}";
application.BenefitIssueDate = faker.Date.Recent(120);
application.BenefitExpirationDate = application.BenefitIssueDate.Value.AddDays(faker.Random.Int(30, 365));
@@ -135,7 +135,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
}
else if (status == ApplicationStatus.Denied)
{
- application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
+ application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
application.CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}";
if (faker.Random.Bool(0.5f))
{
@@ -164,7 +164,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
else
{
// For other statuses (Pending, UnderReview, Cancelled)
- application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
+ application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
if (faker.Random.Bool(0.5f))
{
application.CardStatus = CardStatus.Requested;
diff --git a/src/SEBT.Portal.UseCases/Dependencies.cs b/src/SEBT.Portal.UseCases/Dependencies.cs
index 3efd0f55..27440f7e 100644
--- a/src/SEBT.Portal.UseCases/Dependencies.cs
+++ b/src/SEBT.Portal.UseCases/Dependencies.cs
@@ -23,6 +23,7 @@ public static IServiceCollection AddUseCases(this IServiceCollection services)
services.RegisterCommandHandler();
services.RegisterCommandHandler();
services.RegisterCommandHandler();
+ services.RegisterCommandHandler();
return services;
}
diff --git a/src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommand.cs b/src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommand.cs
new file mode 100644
index 00000000..cbff8912
--- /dev/null
+++ b/src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommand.cs
@@ -0,0 +1,25 @@
+using System.ComponentModel.DataAnnotations;
+using System.Security.Claims;
+using SEBT.Portal.Kernel;
+
+namespace SEBT.Portal.UseCases.Household;
+
+///
+/// Command to request replacement cards for one or more applications.
+///
+public class RequestCardReplacementCommand : ICommand
+{
+ ///
+ /// The authenticated user's claims principal, used to resolve household identity.
+ ///
+ [Required]
+ public required ClaimsPrincipal User { get; init; }
+
+ ///
+ /// Application numbers identifying which cards to replace.
+ /// All children on a selected application share the same card.
+ ///
+ [Required(ErrorMessage = "At least one application number is required.")]
+ [MinLength(1, ErrorMessage = "At least one application number is required.")]
+ public required List ApplicationNumbers { get; init; }
+}
diff --git a/src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommandHandler.cs b/src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommandHandler.cs
new file mode 100644
index 00000000..17121d1b
--- /dev/null
+++ b/src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommandHandler.cs
@@ -0,0 +1,119 @@
+using Microsoft.Extensions.Logging;
+using SEBT.Portal.Core.Models;
+using SEBT.Portal.Core.Models.Auth;
+using SEBT.Portal.Core.Repositories;
+using SEBT.Portal.Core.Services;
+using SEBT.Portal.Kernel;
+using SEBT.Portal.Kernel.Results;
+
+namespace SEBT.Portal.UseCases.Household;
+
+///
+/// Handles card replacement requests for an authenticated user's household.
+/// Validates input, resolves household identity, enforces 2-week cooldown, and returns success.
+/// State connector call is stubbed β actual card replacement is a future integration.
+///
+public class RequestCardReplacementCommandHandler(
+ IValidator validator,
+ IHouseholdIdentifierResolver resolver,
+ IHouseholdRepository repository,
+ TimeProvider timeProvider,
+ ILogger logger)
+ : ICommandHandler
+{
+ private static readonly TimeSpan CooldownPeriod = TimeSpan.FromDays(14);
+
+ public async Task Handle(
+ RequestCardReplacementCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var validationResult = await validator.Validate(command, cancellationToken);
+ if (validationResult is ValidationFailedResult validationFailed)
+ {
+ logger.LogWarning("Card replacement validation failed");
+ return Result.ValidationFailed(validationFailed.Errors);
+ }
+
+ var identifier = await resolver.ResolveAsync(command.User, cancellationToken);
+ if (identifier == null)
+ {
+ logger.LogWarning(
+ "Card replacement attempted but no household identifier could be resolved from claims");
+ return Result.Unauthorized("Unable to identify user from token.");
+ }
+
+ var userIalLevel = UserIalLevelExtensions.FromClaimsPrincipal(command.User);
+
+ var household = await repository.GetHouseholdByIdentifierAsync(
+ identifier,
+ new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false),
+ userIalLevel,
+ cancellationToken);
+
+ if (household == null)
+ {
+ logger.LogWarning("Card replacement attempted but household data not found");
+ return Result.PreconditionFailed(PreconditionFailedReason.NotFound, "Household data not found.");
+ }
+
+ var cooldownErrors = CheckCooldown(command.ApplicationNumbers, household, timeProvider);
+ if (cooldownErrors.Count > 0)
+ {
+ logger.LogInformation(
+ "Card replacement rejected: {Count} application(s) within cooldown period",
+ cooldownErrors.Count);
+ return Result.ValidationFailed(cooldownErrors);
+ }
+
+ var identifierKind = identifier.Type.ToString();
+ logger.LogInformation(
+ "Card replacement request received for household identifier kind {Kind}, {Count} application(s)",
+ identifierKind,
+ command.ApplicationNumbers.Count);
+
+ // TODO: Call state connector to process card replacement.
+ // Stubbed β returns success without calling the state system.
+
+ logger.LogInformation(
+ "Card replacement request recorded for household identifier kind {Kind}",
+ identifierKind);
+
+ return Result.Success();
+ }
+
+ private static List CheckCooldown(
+ List requestedApplicationNumbers,
+ Core.Models.Household.HouseholdData household,
+ TimeProvider timeProvider)
+ {
+ var errors = new List();
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+
+ foreach (var appNumber in requestedApplicationNumbers)
+ {
+ var application = household.Applications
+ .FirstOrDefault(a => a.ApplicationNumber == appNumber);
+
+ if (application == null)
+ {
+ errors.Add(new ValidationError(
+ "ApplicationNumbers",
+ $"Application {appNumber} does not belong to this household."));
+ continue;
+ }
+
+ if (application.CardRequestedAt == null)
+ continue;
+
+ var elapsed = now - application.CardRequestedAt.Value;
+ if (elapsed < CooldownPeriod)
+ {
+ errors.Add(new ValidationError(
+ "ApplicationNumbers",
+ $"Application {appNumber} was requested within the last 14 days."));
+ }
+ }
+
+ return errors;
+ }
+}
diff --git a/src/SEBT.Portal.Web/e2e/card-replacement/address-flow.spec.ts b/src/SEBT.Portal.Web/e2e/card-replacement/address-flow.spec.ts
new file mode 100644
index 00000000..5f5da239
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/card-replacement/address-flow.spec.ts
@@ -0,0 +1,178 @@
+import { expect, test } from '@playwright/test'
+
+import { setupApiRoutes } from '../fixtures/api-routes'
+import { injectAuth } from '../fixtures/auth'
+import {
+ makeApplication,
+ makeHouseholdData,
+ makeSummerEbtCase,
+ OLD_CARD_DATE
+} from '../fixtures/household-data'
+import { currentState } from '../fixtures/state'
+
+const ADDRESS_FORM_DATA =
+ currentState === 'co'
+ ? { street: '200 E Colfax Ave', city: 'Denver', state: 'Colorado', zip: '80203' }
+ : {
+ street: '456 Oak Avenue NW',
+ city: 'Washington',
+ state: 'District of Columbia',
+ zip: '20002'
+ }
+
+const EXPECTED_PREFILL =
+ currentState === 'co'
+ ? { street: '200 E Colfax Ave', city: 'Denver', zip: '80203' }
+ : { street: '1350 Pennsylvania Ave NW', city: 'Washington', zip: '20004' }
+
+/**
+ * Fills and submits the address form with valid data.
+ * The form submits via PUT /api/household/address, then stores the address
+ * in AddressFlowContext (React state only) and navigates to the next step.
+ */
+async function fillAndSubmitAddressForm(page: import('@playwright/test').Page) {
+ await page.fill('[name="streetAddress1"]', ADDRESS_FORM_DATA.street)
+ await page.fill('[name="city"]', ADDRESS_FORM_DATA.city)
+ await page.selectOption('[name="state"]', ADDRESS_FORM_DATA.state)
+ await page.fill('[name="postalCode"]', ADDRESS_FORM_DATA.zip)
+ await page.getByRole('button', { name: 'Continue' }).click()
+}
+
+test.describe('Address update flow', () => {
+ test.beforeEach(async ({ page }) => {
+ await injectAuth(page)
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 1 })],
+ applications: [makeApplication({ cardRequestedAt: OLD_CARD_DATE, issuanceType: 1 })]
+ })
+ })
+ })
+
+ test('address form renders with pre-filled data from household API', async ({ page }) => {
+ await page.goto('/profile/address')
+ await expect(page.locator('[name="streetAddress1"]')).toHaveValue(EXPECTED_PREFILL.street)
+ await expect(page.locator('[name="city"]')).toHaveValue(EXPECTED_PREFILL.city)
+ await expect(page.locator('[name="postalCode"]')).toHaveValue(EXPECTED_PREFILL.zip)
+ })
+
+ test('address form submission navigates to replacement card prompt', async ({ page }) => {
+ await page.goto('/profile/address')
+ await fillAndSubmitAddressForm(page)
+ await expect(page).toHaveURL('/profile/address/replacement-cards')
+ })
+
+ test.describe('ReplacementCardPrompt', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/profile/address')
+ await fillAndSubmitAddressForm(page)
+ await expect(page).toHaveURL('/profile/address/replacement-cards')
+ })
+
+ test('shows validation error when submitted without a selection', async ({ page }) => {
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page.locator('.usa-error-message')).toBeVisible()
+ })
+
+ test('"No" selection navigates to dashboard and shows address updated alert', async ({
+ page
+ }) => {
+ // USWDS radio tiles use visually-hidden inputs. Click the label β
+ // it's the visible, clickable target that scrolls into view reliably.
+ await page.locator('label[for="replacement-no"]').click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ // DashboardAlerts captures the addressUpdated param in state then cleans the URL.
+ await expect(page).toHaveURL('/dashboard')
+ await expect(
+ page.locator('.usa-alert--success', { hasText: 'Address update recorded' })
+ ).toBeVisible()
+ })
+
+ test('"Yes" selection navigates to card selection', async ({ page }) => {
+ await page.locator('label[for="replacement-yes"]').click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page).toHaveURL('/profile/address/replacement-cards/select')
+ })
+ })
+
+ test.describe('CardSelection', () => {
+ test.beforeEach(async ({ page }) => {
+ // Navigate through the flow to reach card selection
+ await page.goto('/profile/address')
+ await fillAndSubmitAddressForm(page)
+ await expect(page).toHaveURL('/profile/address/replacement-cards')
+ await page.locator('label[for="replacement-yes"]').click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page).toHaveURL('/profile/address/replacement-cards/select')
+ })
+
+ test('shows validation error when submitted without selecting a card', async ({ page }) => {
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page.locator('.usa-error-message')).toBeVisible()
+ })
+
+ test('selecting a card and continuing navigates to confirm page', async ({ page }) => {
+ // Click the label β USWDS checkbox tiles use visually-hidden inputs.
+ await page.locator('.usa-checkbox__label').first().click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page).toHaveURL(/\/profile\/address\/replacement-cards\/select\/confirm\?apps=/)
+ })
+ })
+
+ test.describe('ConfirmRequest (address flow)', () => {
+ test.beforeEach(async ({ page }) => {
+ // Navigate through the full address + card selection flow
+ await page.goto('/profile/address')
+ await fillAndSubmitAddressForm(page)
+ await page.locator('label[for="replacement-yes"]').click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await page.locator('.usa-checkbox__label').first().click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page).toHaveURL(/\/profile\/address\/replacement-cards\/select\/confirm/)
+ })
+
+ test('shows card order summary with child name', async ({ page }) => {
+ await expect(page.getByText("John Doe's card")).toBeVisible()
+ })
+
+ test('shows mailing address', async ({ page }) => {
+ // The submitted address (from the form, not the household data address on file)
+ await expect(page.locator('address')).toContainText(ADDRESS_FORM_DATA.street)
+ })
+
+ test('"Order card" button posts to replace endpoint and shows success alert', async ({
+ page
+ }) => {
+ await page.getByRole('button', { name: 'Order card' }).click()
+ // DashboardAlerts captures flash param in state then cleans the URL.
+ await expect(page).toHaveURL('/dashboard')
+ await expect(
+ page.locator('.usa-alert--success', {
+ hasText: 'Your replacement card request has been recorded'
+ })
+ ).toBeVisible()
+ })
+ })
+})
+
+test.describe('Address flow: direct navigation guards', () => {
+ test.beforeEach(async ({ page }) => {
+ await injectAuth(page)
+ await setupApiRoutes(page)
+ })
+
+ test('accessing /profile/address/replacement-cards directly redirects to address form', async ({
+ page
+ }) => {
+ // AddressFlowContext is empty on direct load β FlowGuard redirects to the form
+ await page.goto('/profile/address/replacement-cards')
+ await expect(page).toHaveURL('/profile/address')
+ })
+
+ test('accessing /profile/address/replacement-cards/select directly redirects to address form', async ({
+ page
+ }) => {
+ await page.goto('/profile/address/replacement-cards/select')
+ await expect(page).toHaveURL('/profile/address')
+ })
+})
diff --git a/src/SEBT.Portal.Web/e2e/card-replacement/child-card.spec.ts b/src/SEBT.Portal.Web/e2e/card-replacement/child-card.spec.ts
new file mode 100644
index 00000000..50e20201
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/card-replacement/child-card.spec.ts
@@ -0,0 +1,208 @@
+import { expect, test } from '@playwright/test'
+
+import { setupApiRoutes } from '../fixtures/api-routes'
+import { injectAuth } from '../fixtures/auth'
+import { makeHouseholdData, makeSummerEbtCase, recentCardDate } from '../fixtures/household-data'
+import { skipUnlessState } from '../fixtures/state'
+
+test.describe('ChildCard', () => {
+ test.beforeEach(async ({ page }) => {
+ await injectAuth(page)
+ })
+
+ test.describe('issuance type labels', () => {
+ test('SummerEbt (issuanceType 1) shows a card type label under "Benefit issued to"', async ({
+ page
+ }) => {
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 1 })]
+ })
+ })
+ await page.goto('/dashboard')
+ // The card type heading is "Benefit issued to" in both DC and CO locales.
+ // The value text differs by state (e.g. "DC SUN Bucks Card" vs "Summer EBT Card")
+ // so we assert on the heading rather than the translated value.
+ await expect(page.locator('[data-testid="accordion-content"]')).toContainText(
+ 'Benefit issued to'
+ )
+ })
+
+ test('SnapEbtCard (issuanceType 3) shows SNAP card type label', async ({ page }) => {
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 3 })]
+ })
+ })
+ await page.goto('/dashboard')
+ // "SNAP" appears in both DC ("Household SNAP EBT Card") and CO ("SNAP EBT Card")
+ await expect(page.locator('[data-testid="accordion-content"]')).toContainText('SNAP')
+ })
+
+ test('TanfEbtCard (issuanceType 2) shows TANF card type label', async ({ page }) => {
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 2 })]
+ })
+ })
+ await page.goto('/dashboard')
+ // "TANF" appears in both DC ("Household TANF EBT Card") and CO ("Colorado Works (TANF) EBT Card")
+ await expect(page.locator('[data-testid="accordion-content"]')).toContainText('TANF')
+ })
+ })
+
+ test.describe('feature flags', () => {
+ test('show_case_number=true shows SEBT ID row', async ({ page }) => {
+ await setupApiRoutes(page, {
+ featureFlags: { show_case_number: true }
+ })
+ await page.goto('/dashboard')
+ // The ChildCard renders the case number when the flag is on
+ await expect(page.locator('[data-testid="accordion-content"]')).toContainText('CASE-100001')
+ })
+
+ test('show_case_number=false hides SEBT ID row', async ({ page }) => {
+ await setupApiRoutes(page, {
+ featureFlags: { show_case_number: false }
+ })
+ await page.goto('/dashboard')
+ await expect(page.locator('[data-testid="accordion-content"]')).not.toContainText(
+ 'CASE-100001'
+ )
+ })
+
+ test('show_card_last4=true shows card number row', async ({ page }) => {
+ await setupApiRoutes(page, {
+ featureFlags: { show_card_last4: true }
+ })
+ await page.goto('/dashboard')
+ await expect(page.locator('[data-testid="accordion-content"]')).toContainText('1234')
+ })
+
+ test('show_card_last4=false hides card number row', async ({ page }) => {
+ await setupApiRoutes(page, {
+ featureFlags: { show_card_last4: false }
+ })
+ await page.goto('/dashboard')
+ await expect(page.locator('[data-testid="accordion-content"]')).not.toContainText('1234')
+ })
+ })
+
+ test.describe('replacement link visibility', () => {
+ test('shows replacement link when card is not within cooldown', async ({ page }) => {
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 1 })]
+ })
+ })
+ await page.goto('/dashboard')
+ // SummerEbtCase has no cardRequestedAt, so cooldown does not apply.
+ // applicationId maps to applicationNumber, enabling the link.
+ await expect(
+ page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ ).toBeVisible()
+ })
+
+ test('hides replacement link when card was requested within the last 14 days (cooldown)', async ({
+ page
+ }) => {
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [
+ makeSummerEbtCase({ issuanceType: 1, cardRequestedAt: recentCardDate() })
+ ]
+ })
+ })
+ await page.goto('/dashboard')
+ await expect(
+ page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ ).not.toBeVisible()
+ })
+
+ test('replacement link points to /cards/replace for SummerEbt', async ({ page }) => {
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [
+ makeSummerEbtCase({
+ applicationId: 'APP-2026-001',
+ issuanceType: 1
+ })
+ ]
+ })
+ })
+ await page.goto('/dashboard')
+ const link = page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ await expect(link).toHaveAttribute('href', /\/cards\/replace\?app=APP-2026-001/)
+ })
+
+ test('DC: SnapEbtCard co-loaded shows /cards/info replacement link', async ({ page }) => {
+ skipUnlessState('dc')
+
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 3 })]
+ })
+ })
+ await page.goto('/dashboard')
+
+ const link = page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ await expect(link).toHaveAttribute('href', '/cards/info')
+ })
+
+ test('CO: SnapEbtCard co-loaded shows no replacement link', async ({ page }) => {
+ skipUnlessState('co')
+
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 3 })]
+ })
+ })
+ await page.goto('/dashboard')
+
+ const link = page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ await expect(link).toHaveCount(0)
+ })
+
+ test('DC: TanfEbtCard co-loaded shows /cards/info replacement link', async ({ page }) => {
+ skipUnlessState('dc')
+
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 2 })]
+ })
+ })
+ await page.goto('/dashboard')
+
+ const link = page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ await expect(link).toHaveAttribute('href', '/cards/info')
+ })
+
+ test('CO: TanfEbtCard co-loaded shows no replacement link', async ({ page }) => {
+ skipUnlessState('co')
+
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ summerEbtCases: [makeSummerEbtCase({ issuanceType: 2 })]
+ })
+ })
+ await page.goto('/dashboard')
+
+ const link = page.locator('[data-testid="accordion-content"] a', {
+ hasText: 'Request a replacement card'
+ })
+ await expect(link).toHaveCount(0)
+ })
+ })
+})
diff --git a/src/SEBT.Portal.Web/e2e/card-replacement/dashboard-alerts.spec.ts b/src/SEBT.Portal.Web/e2e/card-replacement/dashboard-alerts.spec.ts
new file mode 100644
index 00000000..3e676b7e
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/card-replacement/dashboard-alerts.spec.ts
@@ -0,0 +1,80 @@
+import { expect, test } from '@playwright/test'
+
+import { setupApiRoutes } from '../fixtures/api-routes'
+import { injectAuth } from '../fixtures/auth'
+
+test.describe('DashboardAlerts', () => {
+ test.beforeEach(async ({ page }) => {
+ await injectAuth(page)
+ await setupApiRoutes(page)
+ })
+
+ test('shows no alert on plain dashboard', async ({ page }) => {
+ await page.goto('/dashboard')
+ await expect(page.locator('.usa-alert')).toHaveCount(0)
+ })
+
+ test('shows address updated alert on ?addressUpdated=true', async ({ page }) => {
+ await page.goto('/dashboard?addressUpdated=true')
+ await expect(
+ page.locator('.usa-alert--success', { hasText: 'Address update recorded' })
+ ).toBeVisible()
+ })
+
+ test('shows address + card replacement alert on ?addressUpdated=true&cardsRequested=true', async ({
+ page
+ }) => {
+ await page.goto('/dashboard?addressUpdated=true&cardsRequested=true')
+ await expect(
+ page.locator('.usa-alert--success', {
+ hasText: 'Address update and card replacement recorded'
+ })
+ ).toBeVisible()
+ })
+
+ test('shows card replaced success alert on ?flash=card_replaced', async ({ page }) => {
+ await page.goto('/dashboard?flash=card_replaced')
+ await expect(
+ page.locator('.usa-alert--success', {
+ hasText: 'Your replacement card request has been recorded'
+ })
+ ).toBeVisible()
+ })
+
+ test('shows address update failed warning on ?addressUpdateFailed=true', async ({ page }) => {
+ await page.goto('/dashboard?addressUpdateFailed=true')
+ await expect(
+ page.locator('.usa-alert--warning', {
+ hasText: 'There was an issue updating your mailing address.'
+ })
+ ).toBeVisible()
+ })
+
+ test('shows contact update failed warning on ?contactUpdateFailed=true', async ({ page }) => {
+ await page.goto('/dashboard?contactUpdateFailed=true')
+ await expect(
+ page.locator('.usa-alert--warning', {
+ hasText: 'There was an issue updating your contact preferences.'
+ })
+ ).toBeVisible()
+ })
+
+ test('shows address verification warning on ?addressVerification=true', async ({ page }) => {
+ await page.goto('/dashboard?addressVerification=true')
+ await expect(
+ page.locator('.usa-alert--warning', { hasText: 'Is your address correct?' })
+ ).toBeVisible()
+ })
+
+ test('alert URL params are cleaned from the URL after display', async ({ page }) => {
+ await page.goto('/dashboard?flash=card_replaced')
+ // Alert is visible...
+ await expect(
+ page.locator('.usa-alert--success', {
+ hasText: 'Your replacement card request has been recorded'
+ })
+ ).toBeVisible()
+ // ...but the param has been removed from the URL
+ await expect(page).not.toHaveURL(/flash=card_replaced/)
+ })
+})
diff --git a/src/SEBT.Portal.Web/e2e/card-replacement/standalone-flow.spec.ts b/src/SEBT.Portal.Web/e2e/card-replacement/standalone-flow.spec.ts
new file mode 100644
index 00000000..cdadfdde
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/card-replacement/standalone-flow.spec.ts
@@ -0,0 +1,119 @@
+import { expect, test } from '@playwright/test'
+
+import { setupApiRoutes } from '../fixtures/api-routes'
+import { injectAuth } from '../fixtures/auth'
+import { makeApplication, makeHouseholdData, OLD_CARD_DATE } from '../fixtures/household-data'
+import { currentState } from '../fixtures/state'
+
+const APP_NUMBER = 'APP-2026-001'
+const EXPECTED_ADDRESS = currentState === 'co' ? '200 E Colfax Ave' : '1350 Pennsylvania Ave NW'
+const ENCODED_APP = encodeURIComponent(APP_NUMBER)
+
+test.describe('Standalone replacement flow', () => {
+ test.describe('ConfirmAddress (/cards/replace)', () => {
+ test.beforeEach(async ({ page }) => {
+ await injectAuth(page)
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ applications: [
+ makeApplication({ applicationNumber: APP_NUMBER, cardRequestedAt: OLD_CARD_DATE })
+ ]
+ })
+ })
+ })
+
+ test('renders confirm address page with address on file', async ({ page }) => {
+ await page.goto(`/cards/replace?app=${ENCODED_APP}`)
+ await expect(page.locator('h1')).toContainText(
+ 'Do you want the new card mailed to this address?'
+ )
+ await expect(page.getByText(EXPECTED_ADDRESS)).toBeVisible()
+ })
+
+ test('shows validation error when submitted without a selection', async ({ page }) => {
+ await page.goto(`/cards/replace?app=${ENCODED_APP}`)
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page.locator('.usa-error-message')).toBeVisible()
+ })
+
+ test('"Yes" navigates to confirm request page', async ({ page }) => {
+ await page.goto(`/cards/replace?app=${ENCODED_APP}`)
+ // USWDS radio tiles use visually-hidden inputs. Click the label instead β
+ // it's the visible, clickable target and scrolls into view reliably.
+ await page.locator('label[for="confirm-address-yes"]').click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page).toHaveURL(`/cards/replace/confirm?app=${ENCODED_APP}`)
+ })
+
+ test('"No" navigates to address change page', async ({ page }) => {
+ await page.goto(`/cards/replace?app=${ENCODED_APP}`)
+ await page.locator('label[for="confirm-address-no"]').click()
+ await page.getByRole('button', { name: 'Continue' }).click()
+ await expect(page).toHaveURL(`/cards/replace/address?app=${ENCODED_APP}`)
+ })
+
+ test('missing app param redirects to dashboard', async ({ page }) => {
+ // CardReplaceLayout redirects to /dashboard when ?app is missing.
+ await page.goto('/cards/replace')
+ await expect(page).toHaveURL('/dashboard')
+ })
+
+ test('shows error alert when app param does not match any application', async ({ page }) => {
+ await page.goto('/cards/replace?app=NONEXISTENT')
+ await expect(page.locator('.usa-alert--error')).toBeVisible()
+ })
+ })
+
+ test.describe('ConfirmRequest (/cards/replace/confirm)', () => {
+ test.beforeEach(async ({ page }) => {
+ await injectAuth(page)
+ await setupApiRoutes(page, {
+ householdData: makeHouseholdData({
+ applications: [
+ makeApplication({ applicationNumber: APP_NUMBER, cardRequestedAt: OLD_CARD_DATE })
+ ]
+ })
+ })
+ })
+
+ test('renders confirm request page with child name and address', async ({ page }) => {
+ await page.goto(`/cards/replace/confirm?app=${ENCODED_APP}`)
+ await expect(page.getByText("John Doe's card")).toBeVisible()
+ await expect(page.locator('address')).toContainText(EXPECTED_ADDRESS)
+ })
+
+ test('"Order card" posts to replace endpoint and redirects to dashboard with success alert', async ({
+ page
+ }) => {
+ await page.goto(`/cards/replace/confirm?app=${ENCODED_APP}`)
+ await page.getByRole('button', { name: 'Order card' }).click()
+ // DashboardAlerts captures the flash param in state then calls router.replace() to clean the URL.
+ // Assert on the final URL (param removed) and that the alert is visible.
+ await expect(page).toHaveURL('/dashboard')
+ await expect(
+ page.locator('.usa-alert--success', {
+ hasText: 'Your replacement card request has been recorded'
+ })
+ ).toBeVisible()
+ })
+
+ test('shows error alert on replace API failure', async ({ page }) => {
+ // Re-setup routes with cardReplaceStatus: 500 for this test.
+ // injectAuth/setupApiRoutes from beforeEach are already registered β
+ // Playwright matches routes in registration order, most recent first,
+ // so this override takes precedence for the replace endpoint.
+ await page.route('**/api/household/cards/replace', (route) => {
+ void route.fulfill({ status: 500 })
+ })
+ await page.goto(`/cards/replace/confirm?app=${ENCODED_APP}`)
+ await page.getByRole('button', { name: 'Order card' }).click()
+ await expect(page.locator('.usa-alert--error')).toBeVisible()
+ })
+
+ test('missing app param redirects to dashboard', async ({ page }) => {
+ // CardReplaceLayout redirects to /dashboard when ?app is missing.
+ await page.goto('/cards/replace/confirm')
+ await expect(page).toHaveURL('/dashboard')
+ })
+ })
+})
diff --git a/src/SEBT.Portal.Web/e2e/fixtures/api-routes.ts b/src/SEBT.Portal.Web/e2e/fixtures/api-routes.ts
new file mode 100644
index 00000000..4309ca34
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/fixtures/api-routes.ts
@@ -0,0 +1,78 @@
+import type { Page } from '@playwright/test'
+
+import {
+ DEFAULT_FEATURE_FLAGS,
+ MOCK_JWT,
+ makeHouseholdData,
+ type MockHouseholdData
+} from './household-data'
+
+interface ApiRouteOverrides {
+ /** Override the household data response. Defaults to makeHouseholdData(). */
+ householdData?: MockHouseholdData
+ /** Override specific feature flags. Merged with DEFAULT_FEATURE_FLAGS. */
+ featureFlags?: Partial
+ /**
+ * Override the PUT /api/household/address response status.
+ * Defaults to 204 (success).
+ */
+ addressUpdateStatus?: number
+ /**
+ * Override the POST /api/household/cards/replace response status.
+ * Defaults to 204 (success).
+ */
+ cardReplaceStatus?: number
+}
+
+/**
+ * Intercepts all backend API calls and returns controlled mock responses.
+ * Call before page.goto() β route handlers are registered at call time and
+ * apply to all subsequent navigations on this page object.
+ *
+ * The Next.js proxy forwards /api/* to the backend. Playwright's page.route()
+ * intercepts at the browser level, so it catches these proxied requests before
+ * they leave the browser.
+ *
+ * auth/refresh must be intercepted here: if it returns 401, apiFetch clears
+ * the token and redirects to /login, which would break all authenticated tests.
+ * We return a success response with the same mock token to keep the session alive.
+ */
+export async function setupApiRoutes(page: Page, overrides: ApiRouteOverrides = {}): Promise {
+ const householdData = overrides.householdData ?? makeHouseholdData()
+ const featureFlags = { ...DEFAULT_FEATURE_FLAGS, ...(overrides.featureFlags ?? {}) }
+ const addressUpdateStatus = overrides.addressUpdateStatus ?? 204
+ const cardReplaceStatus = overrides.cardReplaceStatus ?? 204
+
+ // Keep the mock session alive β a 401 here would clear the token and redirect to /login.
+ await page.route('**/api/auth/refresh', (route) => {
+ void route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ token: MOCK_JWT, requiresIdProofing: false })
+ })
+ })
+
+ await page.route('**/api/household/data', (route) => {
+ void route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(householdData)
+ })
+ })
+
+ await page.route('**/api/features', (route) => {
+ void route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(featureFlags)
+ })
+ })
+
+ await page.route('**/api/household/address', (route) => {
+ void route.fulfill({ status: addressUpdateStatus })
+ })
+
+ await page.route('**/api/household/cards/replace', (route) => {
+ void route.fulfill({ status: cardReplaceStatus })
+ })
+}
diff --git a/src/SEBT.Portal.Web/e2e/fixtures/auth.ts b/src/SEBT.Portal.Web/e2e/fixtures/auth.ts
new file mode 100644
index 00000000..45d9f43e
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/fixtures/auth.ts
@@ -0,0 +1,26 @@
+import type { Page } from '@playwright/test'
+
+import { MOCK_JWT } from './household-data'
+
+/**
+ * The session storage key used by the auth context.
+ * Must match AUTH_TOKEN_KEY in src/features/auth/context/AuthContext.tsx.
+ */
+const AUTH_TOKEN_KEY = 'auth_token'
+
+/**
+ * Injects a mock auth token into sessionStorage before the page loads.
+ * Must be called before page.goto() because addInitScript runs at page creation.
+ *
+ * AuthGuard reads sessionStorage synchronously on hydration. Setting the token
+ * via initScript ensures it's present before the React tree mounts, preventing
+ * the redirect-to-login that would otherwise block navigation.
+ */
+export async function injectAuth(page: Page, token = MOCK_JWT): Promise {
+ await page.addInitScript(
+ ({ key, value }) => {
+ sessionStorage.setItem(key, value)
+ },
+ { key: AUTH_TOKEN_KEY, value: token }
+ )
+}
diff --git a/src/SEBT.Portal.Web/e2e/fixtures/household-data.ts b/src/SEBT.Portal.Web/e2e/fixtures/household-data.ts
new file mode 100644
index 00000000..62fe42cf
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/fixtures/household-data.ts
@@ -0,0 +1,238 @@
+/**
+ * Factory functions for mock API responses used in card-replacement E2E tests.
+ *
+ * Integer enum values match what the real .NET backend returns.
+ * The frontend Zod schema preprocesses them to string enums before use.
+ *
+ * IssuanceType: 0=Unknown, 1=SummerEbt, 2=TanfEbtCard, 3=SnapEbtCard
+ * ApplicationStatus: 0=Unknown, 1=Pending, 2=Approved, 3=Denied, 4=UnderReview, 5=Cancelled
+ * CardStatus: 0=Requested, 1=Mailed, 2=Active, 3=Deactivated
+ */
+
+import { currentState, type StateCode } from './state'
+
+/**
+ * A minimal, structurally valid JWT for E2E tests.
+ * The payload claims don't need to be real β the backend is fully intercepted.
+ * Exported here so api-routes.ts can return it from the auth/refresh intercept.
+ *
+ * Payload: { sub, exp, ial: "1plus", id_proofing_completed_at: 1775000000 }
+ * The IAL and id-proofing claims satisfy CO's IalGuard. DC ignores them.
+ * id_proofing_completed_at 1775000000 (~Apr 2026) stays fresh for the 5-year window.
+ */
+export const MOCK_JWT =
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' +
+ 'eyJzdWIiOiJ0ZXN0LXVzZXIiLCJleHAiOjk5OTk5OTk5OTksImlhbCI6IjFwbHVzIiwiaWRfcHJvb2ZpbmdfY29tcGxldGVkX2F0IjoxNzc1MDAwMDAwfQ.' +
+ 'mock-signature-not-verified-in-e2e'
+
+export type IssuanceTypeInt = 0 | 1 | 2 | 3
+export type ApplicationStatusInt = 0 | 1 | 2 | 3 | 4 | 5
+export type CardStatusInt = 0 | 1 | 2 | 3
+
+export interface MockApplication {
+ applicationNumber: string
+ caseNumber: string
+ applicationStatus: ApplicationStatusInt
+ benefitIssueDate: string
+ benefitExpirationDate: string
+ last4DigitsOfCard: string | null
+ cardStatus: CardStatusInt
+ cardRequestedAt: string | null
+ cardMailedAt: string | null
+ cardActivatedAt: string | null
+ cardDeactivatedAt: string | null
+ children: Array<{ caseNumber: number; firstName: string; lastName: string }>
+ childrenOnApplication: number
+ issuanceType: IssuanceTypeInt
+}
+
+export interface MockSummerEbtCase {
+ summerEBTCaseID: string
+ applicationId: string
+ applicationStudentId: string | null
+ childFirstName: string
+ childLastName: string
+ childDateOfBirth: string
+ householdType: string
+ eligibilityType: string
+ applicationDate: string | null
+ applicationStatus: ApplicationStatusInt | null
+ ebtCaseNumber: string
+ ebtCardLastFour: string | null
+ ebtCardStatus: string | null
+ ebtCardIssueDate: string | null
+ ebtCardBalance: number | null
+ benefitAvailableDate: string
+ benefitExpirationDate: string
+ eligibilitySource: string | null
+ issuanceType: IssuanceTypeInt
+}
+
+interface MockAddress {
+ streetAddress1: string
+ streetAddress2: string | null
+ city: string
+ state: string
+ postalCode: string
+}
+
+export interface MockHouseholdData {
+ email: string
+ phone: string
+ summerEbtCases: MockSummerEbtCase[]
+ applications: MockApplication[]
+ addressOnFile: MockAddress
+ userProfile: { firstName: string; middleName: string; lastName: string }
+ benefitIssuanceType: IssuanceTypeInt
+}
+
+/** A date string well outside the 14-day cooldown window. */
+export const OLD_CARD_DATE = '2025-01-01T00:00:00Z'
+
+/** A date string within the 14-day cooldown window (today minus 1 day). */
+export function recentCardDate(): string {
+ const d = new Date()
+ d.setDate(d.getDate() - 1)
+ return d.toISOString()
+}
+
+// βββ Application factory (legacy, used by some flow tests) βββββββββββββββββ
+
+interface ApplicationOptions {
+ applicationNumber?: string
+ caseNumber?: string
+ issuanceType?: IssuanceTypeInt
+ cardRequestedAt?: string | null
+ cardStatus?: CardStatusInt
+ last4DigitsOfCard?: string | null
+ children?: Array<{ caseNumber: number; firstName: string; lastName: string }>
+}
+
+export function makeApplication(overrides: ApplicationOptions = {}): MockApplication {
+ return {
+ applicationNumber: 'APP-2026-001',
+ caseNumber: 'CASE-100001',
+ applicationStatus: 2, // Approved
+ benefitIssueDate: '2026-01-08T00:00:00Z',
+ benefitExpirationDate: '2026-09-30T00:00:00Z',
+ last4DigitsOfCard: '1234',
+ cardStatus: 2, // Active
+ cardRequestedAt: OLD_CARD_DATE,
+ cardMailedAt: '2025-01-15T00:00:00Z',
+ cardActivatedAt: '2025-01-20T00:00:00Z',
+ cardDeactivatedAt: null,
+ children: [{ caseNumber: 456001, firstName: 'John', lastName: 'Doe' }],
+ childrenOnApplication: 1,
+ issuanceType: 1, // SummerEbt
+ ...overrides
+ }
+}
+
+// βββ SummerEbtCase factory βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface SummerEbtCaseOptions {
+ summerEBTCaseID?: string
+ applicationId?: string
+ childFirstName?: string
+ childLastName?: string
+ childDateOfBirth?: string
+ householdType?: string
+ eligibilityType?: string
+ ebtCaseNumber?: string
+ ebtCardLastFour?: string | null
+ ebtCardStatus?: string | null
+ benefitAvailableDate?: string
+ benefitExpirationDate?: string
+ issuanceType?: IssuanceTypeInt
+ /** Extra fields passed through to the spread (e.g. cardRequestedAt for cooldown tests) */
+ [key: string]: unknown
+}
+
+export function makeSummerEbtCase(overrides: SummerEbtCaseOptions = {}): MockSummerEbtCase {
+ const {
+ summerEBTCaseID = 'SEBT-001',
+ applicationId = 'APP-2026-001',
+ childFirstName = 'John',
+ childLastName = 'Doe',
+ childDateOfBirth = '2015-06-15T00:00:00Z',
+ householdType = 'SNAP',
+ eligibilityType = 'Direct',
+ ebtCaseNumber = 'CASE-100001',
+ ebtCardLastFour = '1234',
+ ebtCardStatus = 'Active',
+ benefitAvailableDate = '2026-01-08T00:00:00Z',
+ benefitExpirationDate = '2026-09-30T00:00:00Z',
+ issuanceType = 1, // SummerEbt
+ ...extra
+ } = overrides
+
+ return {
+ summerEBTCaseID,
+ applicationId,
+ applicationStudentId: null,
+ childFirstName,
+ childLastName,
+ childDateOfBirth,
+ householdType,
+ eligibilityType,
+ applicationDate: null,
+ applicationStatus: 2, // Approved
+ ebtCaseNumber,
+ ebtCardLastFour,
+ ebtCardStatus,
+ ebtCardIssueDate: null,
+ ebtCardBalance: null,
+ benefitAvailableDate,
+ benefitExpirationDate,
+ eligibilitySource: null,
+ issuanceType,
+ ...extra
+ } as MockSummerEbtCase
+}
+
+// βββ HouseholdData factory βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const ADDRESS_DEFAULTS: Record = {
+ dc: {
+ streetAddress1: '1350 Pennsylvania Ave NW',
+ streetAddress2: 'Suite 400',
+ city: 'Washington',
+ state: 'DC',
+ postalCode: '20004'
+ },
+ co: {
+ streetAddress1: '200 E Colfax Ave',
+ streetAddress2: null,
+ city: 'Denver',
+ state: 'CO',
+ postalCode: '80203'
+ }
+}
+
+interface HouseholdDataOptions {
+ summerEbtCases?: MockSummerEbtCase[]
+ applications?: MockApplication[]
+ benefitIssuanceType?: IssuanceTypeInt
+ addressOnFile?: MockAddress
+}
+
+export function makeHouseholdData(overrides: HouseholdDataOptions = {}): MockHouseholdData {
+ return {
+ email: 'test@example.com',
+ phone: '(202) 555-0100',
+ summerEbtCases: overrides.summerEbtCases ?? [makeSummerEbtCase()],
+ applications: overrides.applications ?? [makeApplication()],
+ addressOnFile: overrides.addressOnFile ?? ADDRESS_DEFAULTS[currentState],
+ userProfile: { firstName: 'Jane', middleName: 'M', lastName: 'Doe' },
+ benefitIssuanceType: overrides.benefitIssuanceType ?? 1
+ }
+}
+
+export const DEFAULT_FEATURE_FLAGS = {
+ enable_enrollment_status: true,
+ enable_card_replacement: true,
+ enable_spanish_support: true,
+ show_application_number: true,
+ show_case_number: true,
+ show_card_last4: true
+}
diff --git a/src/SEBT.Portal.Web/e2e/fixtures/state.ts b/src/SEBT.Portal.Web/e2e/fixtures/state.ts
new file mode 100644
index 00000000..57fa4ec3
--- /dev/null
+++ b/src/SEBT.Portal.Web/e2e/fixtures/state.ts
@@ -0,0 +1,9 @@
+import { test } from '@playwright/test'
+
+export type StateCode = 'dc' | 'co'
+
+export const currentState: StateCode = (process.env.NEXT_PUBLIC_STATE as StateCode) ?? 'dc'
+
+export function skipUnlessState(expected: StateCode): void {
+ test.skip(currentState !== expected, `Requires ${expected} build (running ${currentState})`)
+}
diff --git a/src/SEBT.Portal.Web/next.config.ts b/src/SEBT.Portal.Web/next.config.ts
index 4e7866e8..0d1a01a3 100644
--- a/src/SEBT.Portal.Web/next.config.ts
+++ b/src/SEBT.Portal.Web/next.config.ts
@@ -35,6 +35,7 @@ const nextConfig: NextConfig = {
* `includePaths` is legacy-API-only and is ignored β without loadPaths, `@use "uswds-core"` fails under webpack. */
sassOptions: {
implementation: 'sass-embedded',
+ quietDeps: true,
loadPaths: [
path.join(designSystemPath, 'design/sass'),
path.join(__dirname, 'node_modules/@uswds/uswds/packages'),
@@ -51,6 +52,7 @@ const nextConfig: NextConfig = {
options: {
implementation: 'sass-embedded',
sassOptions: {
+ quietDeps: true,
loadPaths: [
path.join(designSystemPath, 'design/sass'),
path.join(__dirname, 'node_modules/@uswds/uswds/packages'),
diff --git a/src/SEBT.Portal.Web/playwright.config.ts b/src/SEBT.Portal.Web/playwright.config.ts
index 26ce932b..90a89053 100644
--- a/src/SEBT.Portal.Web/playwright.config.ts
+++ b/src/SEBT.Portal.Web/playwright.config.ts
@@ -15,7 +15,8 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
...(process.env.CI ? { workers: 1 } : {}),
- reporter: 'html',
+ globalTimeout: process.env.CI ? 600_000 : 0,
+ reporter: process.env.CI ? [['list'], ['html']] : 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
diff --git a/src/SEBT.Portal.Web/src/app/(authenticated)/cards/info/page.tsx b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/info/page.tsx
new file mode 100644
index 00000000..ff391656
--- /dev/null
+++ b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/info/page.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import Link from 'next/link'
+
+import { CoLoadedInfo } from '@/features/address/components/CoLoadedInfo'
+import { Alert, getState } from '@sebt/design-system'
+
+export default function CardInfoPage() {
+ const { t } = useTranslation('confirmInfo')
+ const router = useRouter()
+ const isDC = getState() === 'dc'
+
+ useEffect(() => {
+ if (!isDC) {
+ router.replace('/dashboard')
+ }
+ }, [isDC, router])
+
+ if (!isDC) {
+ return (
+
+ Loading...
+
+ )
+ }
+
+ return (
+
+
+ {t('coLoadedInfoTitle', 'Getting a replacement SNAP or TANF EBT card')}
+
+
+
+ {/* TODO: Use t('coLoadedSunBucksNote') once key is available in CSV */}
+ You can get a new DC SUN Bucks card if you need one. Go to the portal dashboard and tap
+ "Request a replacement card" under the child's name that has benefits issued
+ to a DC SUN Bucks card.{' '}
+
+ Go to the dashboard
+
+
+
+
+
+ )
+}
diff --git a/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/address/page.tsx b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/address/page.tsx
new file mode 100644
index 00000000..56c89367
--- /dev/null
+++ b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/address/page.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { useSearchParams } from 'next/navigation'
+import { useTranslation } from 'react-i18next'
+
+import { AddressFlowProvider } from '@/features/address'
+import { AddressForm } from '@/features/address/components/AddressForm'
+import { useHouseholdData } from '@/features/household'
+import { Alert } from '@sebt/design-system'
+
+export default function CardReplaceAddressPage() {
+ const { t } = useTranslation('confirmInfo')
+ const { t: tCommon } = useTranslation('common')
+ const searchParams = useSearchParams()
+ const { data, isLoading, isError } = useHouseholdData()
+
+ const appNumber = searchParams.get('app')
+
+ if (isLoading) {
+ return
+ {/* TODO: Use t('confirmReplacementTitle') once key is available in CSV */}A few things to
+ know before replacing {programName} cards
+
+
+
+
+ {/* TODO: Use t('confirmDeactivation') once key is available in CSV */}
+ Once a replacement card is created, the previous card will be permanently deactivated
+
+
+ {/* TODO: Use t('confirmDelivery') once key is available in CSV */}
+ Cards will arrive by mail in around 7-10 business days
+
+
+ {/* TODO: Use t('confirmBalanceRollover') once key is available in CSV */}
+ Any remaining balance on the previous card will automatically be rolled over to the
+ replacement card
+
+
+
+
+
+
+ {/* TODO: Use t('cardOrderSummary') once key is available in CSV */}
+ Card order summary
+