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

{tCommon('loading', 'Loading...')}

+ } + + if (isError || !data || !appNumber) { + return Unable to load address information. Please try again. + } + + return ( +
+

+ {t('addressUpdateTitle', 'Update your mailing address')} +

+ + + +
+ ) +} diff --git a/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/confirm/page.tsx b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/confirm/page.tsx new file mode 100644 index 00000000..2c423562 --- /dev/null +++ b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/confirm/page.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useRouter, useSearchParams } from 'next/navigation' +import { useTranslation } from 'react-i18next' + +import { ConfirmRequest } from '@/features/cards/components/ConfirmRequest' +import { useHouseholdData } from '@/features/household' +import { Alert } from '@sebt/design-system' + +export default function CardReplaceConfirmPage() { + const { t: tCommon } = useTranslation('common') + const router = useRouter() + const searchParams = useSearchParams() + const { data, isLoading, isError } = useHouseholdData() + + const appNumber = searchParams.get('app') + + if (isLoading) { + return

{tCommon('loading', 'Loading...')}

+ } + + if (isError || !data || !appNumber) { + return Unable to load card replacement details. Please try again. + } + + const application = data.applications.find((a) => a.applicationNumber === appNumber) + const address = data.addressOnFile + + if (!application || !address) { + return ( + + Card or address information not found. Please return to the dashboard. + + ) + } + + return ( +
+ router.back()} + /> +
+ ) +} diff --git a/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/layout.tsx b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/layout.tsx new file mode 100644 index 00000000..36673552 --- /dev/null +++ b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/layout.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useRouter, useSearchParams } from 'next/navigation' +import type { ReactNode } from 'react' +import { useEffect } from 'react' + +/** + * Layout for the standalone card replacement flow. + * Guards against missing `app` query param (required to identify which application to replace). + */ +export default function CardReplaceLayout({ children }: { children: ReactNode }) { + const searchParams = useSearchParams() + const router = useRouter() + const appParam = searchParams.get('app') + + useEffect(() => { + if (!appParam) { + router.replace('/dashboard') + } + }, [appParam, router]) + + if (!appParam) { + return ( +
+ Loading... +
+ ) + } + + return <>{children} +} diff --git a/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/page.tsx b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/page.tsx new file mode 100644 index 00000000..0dbc4c66 --- /dev/null +++ b/src/SEBT.Portal.Web/src/app/(authenticated)/cards/replace/page.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useSearchParams } from 'next/navigation' +import { useTranslation } from 'react-i18next' + +import { ConfirmAddress } from '@/features/cards/components/ConfirmAddress' +import { useHouseholdData } from '@/features/household' +import { Alert } from '@sebt/design-system' + +export default function CardReplacePage() { + const { t: tCommon } = useTranslation('common') + const searchParams = useSearchParams() + const { data, isLoading, isError } = useHouseholdData() + + const appNumber = searchParams.get('app') + + if (isLoading) { + return

{tCommon('loading', 'Loading...')}

+ } + + if (isError || !data || !appNumber) { + return Unable to load card details. Please try again. + } + + const application = data.applications.find((a) => a.applicationNumber === appNumber) + const address = data.addressOnFile + + if (!application || !address) { + return ( + + Card or address information not found. Please return to the dashboard. + + ) + } + + return ( +
+

+ {/* TODO: Use t('confirmAddressTitle') once key is available in CSV */} + Do you want the new card mailed to this address? +

+ +
+ ) +} diff --git a/src/SEBT.Portal.Web/src/app/(authenticated)/profile/address/(flow)/replacement-cards/select/confirm/page.tsx b/src/SEBT.Portal.Web/src/app/(authenticated)/profile/address/(flow)/replacement-cards/select/confirm/page.tsx new file mode 100644 index 00000000..551a852e --- /dev/null +++ b/src/SEBT.Portal.Web/src/app/(authenticated)/profile/address/(flow)/replacement-cards/select/confirm/page.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useRouter, useSearchParams } from 'next/navigation' +import { useTranslation } from 'react-i18next' + +import { useAddressFlow } from '@/features/address' +import { ConfirmRequest } from '@/features/cards/components/ConfirmRequest' +import { useHouseholdData } from '@/features/household' +import { Alert } from '@sebt/design-system' + +export default function ConfirmCardReplacementPage() { + const { t: tCommon } = useTranslation('common') + const router = useRouter() + const searchParams = useSearchParams() + const { address } = useAddressFlow() + const { data, isLoading, isError } = useHouseholdData() + + const appsParam = searchParams.get('apps') + const selectedAppNumbers = appsParam ? appsParam.split(',') : [] + + if (isLoading) { + return

{tCommon('loading', 'Loading...')}

+ } + + if (isError || !data || !address) { + return Unable to load card replacement details. Please try again. + } + + const selectedApplications = data.applications.filter( + (app) => app.applicationNumber && selectedAppNumbers.includes(app.applicationNumber) + ) + + if (selectedApplications.length === 0) { + return ( + + No matching cards found. Please go back and select cards to replace. + + ) + } + + const addressForConfirm = { + streetAddress1: address.streetAddress1, + streetAddress2: address.streetAddress2, + city: address.city, + state: address.state, + postalCode: address.postalCode + } + + return ( +
+ router.back()} + /> +
+ ) +} diff --git a/src/SEBT.Portal.Web/src/app/(public)/callback/page.test.tsx b/src/SEBT.Portal.Web/src/app/(public)/callback/page.test.tsx index 452d6713..aa7b5383 100644 --- a/src/SEBT.Portal.Web/src/app/(public)/callback/page.test.tsx +++ b/src/SEBT.Portal.Web/src/app/(public)/callback/page.test.tsx @@ -66,9 +66,13 @@ vi.mock('@/lib/translations', () => ({ })) // Mock state -vi.mock('@/lib/state', () => ({ - getState: () => 'co' -})) +vi.mock('@sebt/design-system', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getState: () => 'co' + } +}) // Mock PKCE storage const mockGetPkce = vi.fn() diff --git a/src/SEBT.Portal.Web/src/features/address/components/AddressForm/AddressForm.tsx b/src/SEBT.Portal.Web/src/features/address/components/AddressForm/AddressForm.tsx index 2bcf7d30..766a179b 100644 --- a/src/SEBT.Portal.Web/src/features/address/components/AddressForm/AddressForm.tsx +++ b/src/SEBT.Portal.Web/src/features/address/components/AddressForm/AddressForm.tsx @@ -15,6 +15,8 @@ import { STATE_ABBREVIATIONS, US_STATES } from './usStates' interface AddressFormProps { initialAddress: Address | null + /** Override the default redirect path after successful address update. */ + redirectPath?: string } interface FieldErrors { @@ -38,7 +40,9 @@ function resolveStateValue(value: string | null | undefined, fallback: string): return fallback } -export function AddressForm({ initialAddress }: AddressFormProps) { +const DEFAULT_REDIRECT = '/profile/address/replacement-cards' + +export function AddressForm({ initialAddress, redirectPath }: AddressFormProps) { const { t } = useTranslation('confirmInfo') const { t: tValidation } = useTranslation('validation') const { t: tCommon } = useTranslation('common') @@ -117,7 +121,7 @@ export function AddressForm({ initialAddress }: AddressFormProps) { try { await updateAddress.mutateAsync(addressData) setAddress(addressData) - router.push('/profile/address/replacement-cards') + router.push(redirectPath ?? DEFAULT_REDIRECT) } catch (err) { void err setSubmitError(t('addressUpdateError', 'Something went wrong. Please try again.')) diff --git a/src/SEBT.Portal.Web/src/features/address/components/CardSelection/index.ts b/src/SEBT.Portal.Web/src/features/address/components/CardSelection/index.ts index f6da30a2..27813330 100644 --- a/src/SEBT.Portal.Web/src/features/address/components/CardSelection/index.ts +++ b/src/SEBT.Portal.Web/src/features/address/components/CardSelection/index.ts @@ -1 +1,2 @@ -export { CardSelection } from './CardSelection' +// Re-export from canonical location (moved to features/cards) +export { CardSelection } from '@/features/cards/components/CardSelection/CardSelection' diff --git a/src/SEBT.Portal.Web/src/features/address/components/CoLoadedInfo/CoLoadedInfo.test.tsx b/src/SEBT.Portal.Web/src/features/address/components/CoLoadedInfo/CoLoadedInfo.test.tsx index 14fd4d36..6a9f37bf 100644 --- a/src/SEBT.Portal.Web/src/features/address/components/CoLoadedInfo/CoLoadedInfo.test.tsx +++ b/src/SEBT.Portal.Web/src/features/address/components/CoLoadedInfo/CoLoadedInfo.test.tsx @@ -59,9 +59,9 @@ describe('CoLoadedInfo', () => { renderCoLoadedInfo() await waitFor(() => { - expect(screen.getByText('123 Main Street')).toBeInTheDocument() - expect(screen.getByText('Apt 4B')).toBeInTheDocument() - expect(screen.getByText(/Washington, DC 20001/)).toBeInTheDocument() + expect(screen.getByText('1350 Pennsylvania Ave NW')).toBeInTheDocument() + expect(screen.getByText('Suite 400')).toBeInTheDocument() + expect(screen.getByText(/Washington, DC 20004/)).toBeInTheDocument() }) }) @@ -84,7 +84,7 @@ describe('CoLoadedInfo', () => { expect(screen.getByText(/\(888\) 304-9167/)).toBeInTheDocument() }) - expect(screen.queryByText('123 Main Street')).not.toBeInTheDocument() + expect(screen.queryByText('1350 Pennsylvania Ave NW')).not.toBeInTheDocument() }) it('renders keep-your-card message', async () => { diff --git a/src/SEBT.Portal.Web/src/features/cards/api/client.ts b/src/SEBT.Portal.Web/src/features/cards/api/client.ts new file mode 100644 index 00000000..2acc6d5f --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/api/client.ts @@ -0,0 +1,34 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, apiFetch } from '@/api/client' + +import type { RequestCardReplacementRequest } from './schema' + +const CARD_REPLACEMENT_ENDPOINT = '/household/cards/replace' + +async function requestCardReplacement(data: RequestCardReplacementRequest): Promise { + await apiFetch(CARD_REPLACEMENT_ENDPOINT, { + method: 'POST', + body: data + }) +} + +export function useRequestCardReplacement() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: requestCardReplacement, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['householdData'] }) + }, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return false + } + return failureCount < 2 + }, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000) + }) +} diff --git a/src/SEBT.Portal.Web/src/features/cards/api/schema.test.ts b/src/SEBT.Portal.Web/src/features/cards/api/schema.test.ts new file mode 100644 index 00000000..39d9e2d7 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/api/schema.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { RequestCardReplacementSchema } from './schema' + +describe('RequestCardReplacementSchema', () => { + it('accepts valid request with one application number', () => { + const result = RequestCardReplacementSchema.safeParse({ + applicationNumbers: ['APP-001'] + }) + expect(result.success).toBe(true) + }) + + it('accepts valid request with multiple application numbers', () => { + const result = RequestCardReplacementSchema.safeParse({ + applicationNumbers: ['APP-001', 'APP-002', 'APP-003'] + }) + expect(result.success).toBe(true) + }) + + it('rejects empty application numbers array', () => { + const result = RequestCardReplacementSchema.safeParse({ + applicationNumbers: [] + }) + expect(result.success).toBe(false) + }) + + it('rejects missing applicationNumbers field', () => { + const result = RequestCardReplacementSchema.safeParse({}) + expect(result.success).toBe(false) + }) +}) diff --git a/src/SEBT.Portal.Web/src/features/cards/api/schema.ts b/src/SEBT.Portal.Web/src/features/cards/api/schema.ts new file mode 100644 index 00000000..d5433e1b --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/api/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const RequestCardReplacementSchema = z.object({ + applicationNumbers: z.array(z.string()).min(1, 'At least one application number is required.') +}) + +export type RequestCardReplacementRequest = z.infer diff --git a/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/CardSelection.test.tsx b/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/CardSelection.test.tsx new file mode 100644 index 00000000..17448f28 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/CardSelection.test.tsx @@ -0,0 +1,338 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { server } from '@/mocks/server' + +import { CardSelection } from './CardSelection' + +const mockPush = vi.fn() +const mockBack = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack + }) +})) + +let mockState = 'dc' +vi.mock('@sebt/design-system', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getState: () => mockState + } +}) + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false } + } + }) +} + +function renderCardSelection() { + const queryClient = createTestQueryClient() + const user = userEvent.setup() + return { + user, + ...render( + + + + ) + } +} + +const MULTI_CHILD_HOUSEHOLD = { + email: 'test@example.com', + phone: '(303) 555-0100', + benefitIssuanceType: 1, + applications: [ + { + applicationNumber: 'APP-2026-001', + caseNumber: 'CASE-DC-2026-001', + applicationStatus: 'Approved', + benefitIssueDate: '2026-01-08T00:00:00Z', + benefitExpirationDate: '2026-03-19T00:00:00Z', + last4DigitsOfCard: '1234', + cardStatus: 'Active', + cardRequestedAt: '2026-01-01T00:00:00Z', + cardMailedAt: '2026-01-03T00:00:00Z', + cardActivatedAt: '2026-01-08T00:00:00Z', + cardDeactivatedAt: null, + issuanceType: 1, + children: [ + { caseNumber: 456001, firstName: 'Sophia', lastName: 'Martinez' }, + { caseNumber: 456002, firstName: 'James', lastName: 'Martinez' } + ], + childrenOnApplication: 2 + } + ], + addressOnFile: { + streetAddress1: '123 Main St', + city: 'Washington', + state: 'DC', + postalCode: '20001' + } +} + +describe('CardSelection', () => { + beforeEach(() => { + mockPush.mockClear() + mockBack.mockClear() + mockState = 'dc' + }) + + // --- Rendering children --- + + it('renders checkboxes for each child', async () => { + renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + expect(screen.getByText(/James Martinez/)).toBeInTheDocument() + }) + + const checkboxes = screen.getAllByRole('checkbox') + expect(checkboxes).toHaveLength(2) + }) + + // --- State-specific content --- + + it('shows card number for CO', async () => { + mockState = 'co' + renderCardSelection() + + await waitFor(() => { + expect(screen.getAllByText(/1234 \(last 4 digits\)/)).toHaveLength(2) + }) + }) + + it('does not show card number for DC', async () => { + mockState = 'dc' + renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + expect(screen.queryByText(/last 4 digits/)).not.toBeInTheDocument() + }) + + // --- Validation --- + + it('shows error when submitting without selection', async () => { + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const submitButton = screen.getByRole('button', { name: /continue/i }) + await user.click(submitButton) + + expect(screen.getByText(/select at least one/i)).toBeInTheDocument() + }) + + it('focuses error message on validation failure', async () => { + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const submitButton = screen.getByRole('button', { name: /continue/i }) + await user.click(submitButton) + + const errorMessage = screen.getByText(/select at least one/i) + expect(errorMessage.closest('[tabindex="-1"]')).toHaveFocus() + }) + + it('links error message to fieldset via aria-describedby', async () => { + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const submitButton = screen.getByRole('button', { name: /continue/i }) + await user.click(submitButton) + + const fieldset = screen.getByRole('group', { name: /select which cards/i }) + expect(fieldset).toHaveAttribute('aria-describedby', expect.stringContaining('error')) + }) + + // --- Sibling auto-select (D6) --- + + it('selects all siblings when any child on an application is checked', async () => { + server.use(http.get('/api/household/data', () => HttpResponse.json(MULTI_CHILD_HOUSEHOLD))) + + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]!) + + expect(checkboxes[0]).toBeChecked() + expect(checkboxes[1]).toBeChecked() + }) + + it('disables sibling checkboxes when group is selected', async () => { + server.use(http.get('/api/household/data', () => HttpResponse.json(MULTI_CHILD_HOUSEHOLD))) + + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]!) + + expect(checkboxes[0]).not.toBeDisabled() + expect(checkboxes[1]).toBeDisabled() + }) + + it('shows shared card note on sibling checkboxes', async () => { + server.use(http.get('/api/household/data', () => HttpResponse.json(MULTI_CHILD_HOUSEHOLD))) + + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]!) + + expect(screen.getByText(/share a card/i)).toBeInTheDocument() + }) + + it('deselects all siblings when first child is unchecked', async () => { + server.use(http.get('/api/household/data', () => HttpResponse.json(MULTI_CHILD_HOUSEHOLD))) + + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]!) + await user.click(checkboxes[0]!) + + expect(checkboxes[0]).not.toBeChecked() + expect(checkboxes[1]).not.toBeChecked() + }) + + // --- Successful submission --- + + it('navigates to confirm page with selected application numbers', async () => { + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]!) + + const submitButton = screen.getByRole('button', { name: /continue/i }) + await user.click(submitButton) + + expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('select/confirm?apps=')) + }) + + // --- Error handling --- + + it('shows error alert when household data fails to load', async () => { + server.use( + http.get('/api/household/data', () => { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) + }) + ) + + renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/unable to load household members/i)).toBeInTheDocument() + }) + }) + + // --- Back button --- + + it('navigates back when back button is clicked', async () => { + const { user } = renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + }) + + const backButton = screen.getByRole('button', { name: /back/i }) + await user.click(backButton) + + expect(mockBack).toHaveBeenCalled() + }) + + // --- Null applicationNumber filtering --- + + it('excludes applications without applicationNumber', async () => { + server.use( + http.get('/api/household/data', () => { + return HttpResponse.json({ + email: 'test@example.com', + phone: '(303) 555-0100', + benefitIssuanceType: 1, + applications: [ + { + applicationNumber: null, + applicationStatus: 'Approved', + children: [{ firstName: 'Alice', lastName: 'Smith' }], + childrenOnApplication: 1 + }, + { + applicationNumber: 'APP-002', + applicationStatus: 'Approved', + children: [{ firstName: 'Bob', lastName: 'Smith' }], + childrenOnApplication: 1 + } + ], + addressOnFile: { + streetAddress1: '123 Main St', + city: 'Washington', + state: 'DC', + postalCode: '20001' + } + }) + }) + ) + + renderCardSelection() + + await waitFor(() => { + expect(screen.getByText(/Bob Smith/)).toBeInTheDocument() + }) + + expect(screen.queryByText(/Alice Smith/)).not.toBeInTheDocument() + expect(screen.getAllByRole('checkbox')).toHaveLength(1) + }) + + // --- Accessibility --- + + it('uses fieldset and legend for checkbox group', async () => { + renderCardSelection() + + await waitFor(() => { + const fieldset = screen.getByRole('group', { name: /select which cards/i }) + expect(fieldset).toBeInTheDocument() + }) + }) +}) diff --git a/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/CardSelection.tsx b/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/CardSelection.tsx new file mode 100644 index 00000000..215aa575 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/CardSelection.tsx @@ -0,0 +1,190 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' + +import { isWithinCooldownPeriod } from '@/features/cards/utils/cooldown' +import { useHouseholdData, type Child } from '@/features/household' +import type { Application } from '@/features/household/api/schema' +import { Alert, Button, getState } from '@sebt/design-system' + +interface ApplicationGroup { + applicationNumber: string + children: Child[] + last4DigitsOfCard?: string | null | undefined +} + +function buildApplicationGroups(applications: Application[]): ApplicationGroup[] { + return applications + .filter( + (app): app is Application & { applicationNumber: string } => + app.applicationNumber != null && !isWithinCooldownPeriod(app.cardRequestedAt) + ) + .map((app) => ({ + applicationNumber: app.applicationNumber, + children: app.children, + last4DigitsOfCard: app.last4DigitsOfCard + })) +} + +export function CardSelection() { + const { t } = useTranslation('confirmInfo') + const { t: tCommon } = useTranslation('common') + const router = useRouter() + const currentState = getState() + const { data, isLoading, isError } = useHouseholdData() + + const [selectedApps, setSelectedApps] = useState>(new Set()) + const [error, setError] = useState(null) + const errorRef = useRef(null) + + useEffect(() => { + if (error) { + errorRef.current?.focus() + } + }, [error]) + + if (isLoading) { + return

{tCommon('loading', 'Loading...')}

+ } + + if (isError || !data) { + return ( + + {t('cardSelectionLoadError', 'Unable to load household members. Please try again later.')} + + ) + } + + const groups = buildApplicationGroups(data.applications) + + if (groups.length === 0) { + const hasApplications = data.applications.length > 0 + return ( + + {hasApplications + ? t( + 'cardSelectionAllInCooldown', + 'All cards were recently replaced. Please try again later.' + ) + : t('cardSelectionNoChildren', 'No children found in your household.')} + + ) + } + + function toggleApplication(appNumber: string) { + setSelectedApps((prev) => { + const next = new Set(prev) + if (next.has(appNumber)) { + next.delete(appNumber) + } else { + next.add(appNumber) + } + return next + }) + setError(null) + } + + function handleSubmit(e: FormEvent) { + e.preventDefault() + + if (selectedApps.size === 0) { + setError(t('cardSelectionRequired', 'Please select at least one card.')) + return + } + + const apps = Array.from(selectedApps).join(',') + router.push(`select/confirm?apps=${encodeURIComponent(apps)}`) + } + + return ( +
+

+ {t('requiredFieldsNote', 'Asterisks (*) indicate a required field')} +

+ +
+ + {t('cardSelectionLabel', 'Select which cards you want to replace')} + * + + + {error && ( + + {error} + + )} + + {groups.map((group) => { + const isSelected = selectedApps.has(group.applicationNumber) + const isMultiChild = group.children.length > 1 + + return group.children.map((child, childIndex) => { + const isFirstChild = childIndex === 0 + const isSiblingOfSelected = isSelected && !isFirstChild + + return ( +
+ toggleApplication(group.applicationNumber)} + /> + +
+ ) + }) + })} +
+ +
+ + +
+
+ ) +} diff --git a/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/index.ts b/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/index.ts new file mode 100644 index 00000000..f6da30a2 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/CardSelection/index.ts @@ -0,0 +1 @@ +export { CardSelection } from './CardSelection' diff --git a/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/ConfirmAddress.test.tsx b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/ConfirmAddress.test.tsx new file mode 100644 index 00000000..fd3d5e92 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/ConfirmAddress.test.tsx @@ -0,0 +1,122 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Address, Application } from '@/features/household/api/schema' + +import { ConfirmAddress } from './ConfirmAddress' + +const mockPush = vi.fn() +const mockBack = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack + }) +})) + +let mockState = 'dc' +vi.mock('@sebt/design-system', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getState: () => mockState + } +}) + +const TEST_ADDRESS: Address = { + streetAddress1: '123 Main St', + streetAddress2: 'Apt 4B', + city: 'Washington', + state: 'DC', + postalCode: '20001' +} + +const TEST_APPLICATION: Application = { + applicationNumber: 'APP-001', + caseNumber: 'CASE-001', + applicationStatus: 'Approved', + benefitIssueDate: null, + benefitExpirationDate: null, + last4DigitsOfCard: '1234', + cardStatus: 'Active', + cardRequestedAt: null, + cardMailedAt: null, + cardActivatedAt: null, + cardDeactivatedAt: null, + children: [{ firstName: 'Sophia', lastName: 'Martinez' }], + childrenOnApplication: 1, + issuanceType: 'SummerEbt' +} + +function renderConfirmAddress() { + const user = userEvent.setup() + return { + user, + ...render( + + ) + } +} + +describe('ConfirmAddress', () => { + beforeEach(() => { + mockPush.mockClear() + mockBack.mockClear() + mockState = 'dc' + }) + + it('renders child name subtitle for DC', () => { + renderConfirmAddress() + expect(screen.getByText(/Replace Sophia Martinez/)).toBeInTheDocument() + }) + + it('renders card number subtitle for CO', () => { + mockState = 'co' + renderConfirmAddress() + expect(screen.getByText(/Replace card ending in 1234/)).toBeInTheDocument() + }) + + it('renders the address', () => { + renderConfirmAddress() + expect(screen.getByText(/123 Main St/)).toBeInTheDocument() + expect(screen.getByText(/Apt 4B/)).toBeInTheDocument() + }) + + it('shows error when submitting without selection', async () => { + const { user } = renderConfirmAddress() + const submitButton = screen.getByRole('button', { name: /continue/i }) + await user.click(submitButton) + expect(screen.getByText(/select an option/i)).toBeInTheDocument() + }) + + it('navigates to confirm path when yes is selected', async () => { + const { user } = renderConfirmAddress() + + await user.click(screen.getByLabelText(/yes/i)) + await user.click(screen.getByRole('button', { name: /continue/i })) + + expect(mockPush).toHaveBeenCalledWith('/cards/replace/confirm?app=APP-001') + }) + + it('navigates to change path when no is selected', async () => { + const { user } = renderConfirmAddress() + + await user.click(screen.getByLabelText(/no/i)) + await user.click(screen.getByRole('button', { name: /continue/i })) + + expect(mockPush).toHaveBeenCalledWith('/cards/replace/address?app=APP-001') + }) + + it('navigates back when back button is clicked', async () => { + const { user } = renderConfirmAddress() + await user.click(screen.getByRole('button', { name: /back/i })) + expect(mockBack).toHaveBeenCalled() + }) +}) diff --git a/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/ConfirmAddress.tsx b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/ConfirmAddress.tsx new file mode 100644 index 00000000..aed4f568 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/ConfirmAddress.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' + +import type { Address, Application } from '@/features/household/api/schema' +import { Button, getState } from '@sebt/design-system' + +interface ConfirmAddressProps { + application: Application + address: Address + /** URL to navigate to when user confirms the address. */ + confirmPath: string + /** URL to navigate to when user wants to change the address. */ + changePath: string +} + +export function ConfirmAddress({ + application, + address, + confirmPath, + changePath +}: ConfirmAddressProps) { + const { t } = useTranslation('confirmInfo') + const { t: tCommon } = useTranslation('common') + const router = useRouter() + + const [selection, setSelection] = useState<'yes' | 'no' | null>(null) + const [error, setError] = useState(null) + const errorRef = useRef(null) + + useEffect(() => { + if (error) { + errorRef.current?.focus() + } + }, [error]) + + const currentState = getState() + const childName = application.children[0] + ? `${application.children[0].firstName} ${application.children[0].lastName}` + : '' + const subtitle = + currentState === 'co' && application.last4DigitsOfCard + ? `Replace card ending in ${application.last4DigitsOfCard}` + : childName + ? `Replace ${childName}'s card` + : null + + function handleSubmit(e: FormEvent) { + e.preventDefault() + + if (selection === null) { + setError(t('selectOneError', 'Please select an option.')) + return + } + + if (selection === 'yes') { + router.push(confirmPath) + } else { + router.push(changePath) + } + } + + return ( +
+ {subtitle && ( +

+ {/* TODO: Use t('replaceCardFor') once key is available in CSV */} + {subtitle} +

+ )} + +
+ {address.streetAddress1 && ( +

{address.streetAddress1}

+ )} + {address.streetAddress2 && ( +

{address.streetAddress2}

+ )} +

+ {address.city}, {address.state} {address.postalCode} +

+
+ +

+ {t('requiredFieldsNote', 'Asterisks (*) indicate a required field')} +

+ +
+ + {t('selectOneLabel', 'Select one')} + * + + + {error && ( + + {error} + + )} + +
+ { + setSelection('yes') + setError(null) + }} + /> + +
+ +
+ { + setSelection('no') + setError(null) + }} + /> + +
+
+ +
+ + +
+
+ ) +} diff --git a/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/index.ts b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/index.ts new file mode 100644 index 00000000..6df5769d --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmAddress/index.ts @@ -0,0 +1 @@ +export { ConfirmAddress } from './ConfirmAddress' diff --git a/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/ConfirmRequest.test.tsx b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/ConfirmRequest.test.tsx new file mode 100644 index 00000000..39a21e4b --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/ConfirmRequest.test.tsx @@ -0,0 +1,216 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Address, Application } from '@/features/household/api/schema' +import { server } from '@/mocks/server' + +import { ConfirmRequest } from './ConfirmRequest' + +const mockPush = vi.fn() +const mockBack = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack + }) +})) + +let mockState = 'dc' +vi.mock('@sebt/design-system', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getState: () => mockState + } +}) + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }) +} + +const TEST_ADDRESS: Address = { + streetAddress1: '123 Main St', + streetAddress2: 'Apt 4B', + city: 'Washington', + state: 'DC', + postalCode: '20001' +} + +const TEST_APPLICATIONS: Application[] = [ + { + applicationNumber: 'APP-001', + caseNumber: 'CASE-001', + applicationStatus: 'Approved', + benefitIssueDate: '2026-01-08T00:00:00Z', + benefitExpirationDate: '2026-03-19T00:00:00Z', + last4DigitsOfCard: '1234', + cardStatus: 'Active', + cardRequestedAt: '2026-01-01T00:00:00Z', + cardMailedAt: '2026-01-03T00:00:00Z', + cardActivatedAt: '2026-01-08T00:00:00Z', + cardDeactivatedAt: null, + children: [ + { firstName: 'Sophia', lastName: 'Martinez' }, + { firstName: 'James', lastName: 'Martinez' } + ], + childrenOnApplication: 2, + issuanceType: 'SummerEbt' + } +] + +function renderConfirmRequest(props?: { + applications?: Application[] + address?: Address + onBack?: () => void +}) { + const queryClient = createTestQueryClient() + const user = userEvent.setup() + return { + user, + ...render( + + + + ) + } +} + +describe('ConfirmRequest', () => { + beforeEach(() => { + mockPush.mockClear() + mockBack.mockClear() + mockState = 'dc' + }) + + // --- Content rendering --- + + it('renders the state-specific title for DC', () => { + renderConfirmRequest() + expect(screen.getByText(/DC SUN Bucks/)).toBeInTheDocument() + }) + + it('renders the state-specific title for CO', () => { + mockState = 'co' + renderConfirmRequest() + expect(screen.getByText(/Summer EBT/)).toBeInTheDocument() + }) + + it('renders deactivation, delivery, and balance rollover bullets', () => { + renderConfirmRequest() + expect(screen.getByText(/permanently deactivated/i)).toBeInTheDocument() + expect(screen.getByText(/7.?10 business days/i)).toBeInTheDocument() + expect(screen.getByText(/rolled over/i)).toBeInTheDocument() + }) + + it('renders the card order summary with child names', () => { + renderConfirmRequest() + expect(screen.getByText(/Sophia Martinez/)).toBeInTheDocument() + expect(screen.getByText(/James Martinez/)).toBeInTheDocument() + }) + + it('renders the mailing address', () => { + renderConfirmRequest() + expect(screen.getByText(/123 Main St/)).toBeInTheDocument() + expect(screen.getByText(/Apt 4B/)).toBeInTheDocument() + expect(screen.getByText(/Washington/)).toBeInTheDocument() + }) + + it('shows card number in summary for CO', () => { + mockState = 'co' + renderConfirmRequest() + expect(screen.getAllByText(/1234 \(last 4 digits\)/)).toHaveLength(2) + }) + + it('does not show card number in summary for DC', () => { + mockState = 'dc' + renderConfirmRequest() + expect(screen.queryByText(/last 4 digits/i)).not.toBeInTheDocument() + }) + + // --- Navigation --- + + it('calls onBack when back button is clicked', async () => { + const onBack = vi.fn() + const { user } = renderConfirmRequest({ onBack }) + + const backButton = screen.getByRole('button', { name: /back/i }) + await user.click(backButton) + + expect(onBack).toHaveBeenCalled() + }) + + // --- Submission --- + + it('navigates to dashboard with flash param on successful submission', async () => { + server.use( + http.post('/api/household/cards/replace', () => { + return new HttpResponse(null, { status: 204 }) + }) + ) + + const { user } = renderConfirmRequest() + + const orderButton = screen.getByRole('button', { name: /order card/i }) + await user.click(orderButton) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/dashboard?flash=card_replaced') + }) + }) + + it('shows error message when submission fails', async () => { + server.use( + http.post('/api/household/cards/replace', () => { + return HttpResponse.json({ error: 'Cooldown active' }, { status: 400 }) + }) + ) + + const { user } = renderConfirmRequest() + + const orderButton = screen.getByRole('button', { name: /order card/i }) + await user.click(orderButton) + + await waitFor(() => { + expect(screen.getByText(/issue requesting/i)).toBeInTheDocument() + }) + }) + + it('disables order button while submitting', async () => { + let resolveRequest: () => void + const pending = new Promise((resolve) => { + resolveRequest = resolve + }) + + server.use( + http.post('/api/household/cards/replace', async () => { + await pending + return new HttpResponse(null, { status: 204 }) + }) + ) + + const { user } = renderConfirmRequest() + + const orderButton = screen.getByRole('button', { name: /order card/i }) + await user.click(orderButton) + + expect(orderButton).toBeDisabled() + + resolveRequest!() + await waitFor(() => { + expect(mockPush).toHaveBeenCalled() + }) + }) +}) diff --git a/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/ConfirmRequest.tsx b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/ConfirmRequest.tsx new file mode 100644 index 00000000..e754fb2c --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/ConfirmRequest.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { Address, Application } from '@/features/household/api/schema' +import { Alert, Button, getState } from '@sebt/design-system' + +import { useRequestCardReplacement } from '../../api/client' + +interface ConfirmRequestProps { + applications: Application[] + address: Address + onBack: () => void +} + +function getStateProgramName(state: string): string { + return state === 'dc' ? 'DC SUN Bucks' : 'Summer EBT' +} + +export function ConfirmRequest({ applications, address, onBack }: ConfirmRequestProps) { + const { t } = useTranslation('confirmInfo') + const { t: tCommon } = useTranslation('common') + const router = useRouter() + const currentState = getState() + const mutation = useRequestCardReplacement() + const [error, setError] = useState(null) + + const programName = getStateProgramName(currentState) + const applicationNumbers = applications + .map((app) => app.applicationNumber) + .filter((num): num is string => num != null) + + function handleSubmit() { + setError(null) + mutation.mutate( + { applicationNumbers }, + { + onSuccess: () => { + router.push('/dashboard?flash=card_replaced') + }, + onError: () => { + setError( + t( + 'cardReplacementError', + 'There was an issue requesting your replacement card. Please try again later.' + ) + ) + } + } + ) + } + + 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 +

+ +
    + {applications.flatMap((app) => + app.children.map((child, i) => ( +
  • + + {child.firstName} {child.lastName}'s card + + {currentState === 'co' && app.last4DigitsOfCard && ( + + {/* TODO: Use t('cardNumberLabel') once key is available in CSV */} + Card number: {app.last4DigitsOfCard} (last 4 digits) + + )} +
  • + )) + )} +
+ +

+ {/* TODO: Use t('confirmMailingTo') once key is available in CSV */}A new card will be + mailed to the following address: +

+ +
+ {address.streetAddress1 && ( + {address.streetAddress1} + )} + {address.streetAddress2 && ( + {address.streetAddress2} + )} + + {address.city}, {address.state} {address.postalCode} + +
+
+
+ + {error && ( + + {error} + + )} + +
+ + +
+
+ ) +} diff --git a/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/index.ts b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/index.ts new file mode 100644 index 00000000..0ced5afc --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/components/ConfirmRequest/index.ts @@ -0,0 +1 @@ +export { ConfirmRequest } from './ConfirmRequest' diff --git a/src/SEBT.Portal.Web/src/features/cards/utils/cooldown.test.ts b/src/SEBT.Portal.Web/src/features/cards/utils/cooldown.test.ts new file mode 100644 index 00000000..9e4ed2d4 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/utils/cooldown.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { isWithinCooldownPeriod } from './cooldown' + +describe('isWithinCooldownPeriod', () => { + it('returns true when cardRequestedAt is within 14 days', () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString() + expect(isWithinCooldownPeriod(threeDaysAgo)).toBe(true) + }) + + it('returns true when cardRequestedAt is exactly 13 days ago', () => { + const thirteenDaysAgo = new Date(Date.now() - 13 * 24 * 60 * 60 * 1000).toISOString() + expect(isWithinCooldownPeriod(thirteenDaysAgo)).toBe(true) + }) + + it('returns false when cardRequestedAt is exactly 14 days ago', () => { + const fourteenDaysAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString() + expect(isWithinCooldownPeriod(fourteenDaysAgo)).toBe(false) + }) + + it('returns false when cardRequestedAt is more than 14 days ago', () => { + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + expect(isWithinCooldownPeriod(thirtyDaysAgo)).toBe(false) + }) + + it('returns false when cardRequestedAt is null', () => { + expect(isWithinCooldownPeriod(null)).toBe(false) + }) + + it('returns false when cardRequestedAt is undefined', () => { + expect(isWithinCooldownPeriod(undefined)).toBe(false) + }) + + it('returns false for invalid date string', () => { + expect(isWithinCooldownPeriod('not-a-date')).toBe(false) + }) +}) diff --git a/src/SEBT.Portal.Web/src/features/cards/utils/cooldown.ts b/src/SEBT.Portal.Web/src/features/cards/utils/cooldown.ts new file mode 100644 index 00000000..731779a3 --- /dev/null +++ b/src/SEBT.Portal.Web/src/features/cards/utils/cooldown.ts @@ -0,0 +1,15 @@ +const COOLDOWN_DAYS = 14 +const COOLDOWN_MS = COOLDOWN_DAYS * 24 * 60 * 60 * 1000 + +/** + * Returns true if the card was requested within the last 14 days. + * Timestamp-only check, independent of current card status (D5). + */ +export function isWithinCooldownPeriod(cardRequestedAt: string | null | undefined): boolean { + if (!cardRequestedAt) return false + + const requestedDate = new Date(cardRequestedAt) + if (isNaN(requestedDate.getTime())) return false + + return Date.now() - requestedDate.getTime() < COOLDOWN_MS +} diff --git a/src/SEBT.Portal.Web/src/features/household/api/schema.test.ts b/src/SEBT.Portal.Web/src/features/household/api/schema.test.ts index 5b890ac3..2b4443db 100644 --- a/src/SEBT.Portal.Web/src/features/household/api/schema.test.ts +++ b/src/SEBT.Portal.Web/src/features/household/api/schema.test.ts @@ -1,5 +1,64 @@ import { describe, expect, it } from 'vitest' -import { formatUsPhone, interpolateDate } from './schema' + +import { + ApplicationStatusSchema, + CardStatusSchema, + formatUsPhone, + interpolateDate, + IssuanceTypeSchema +} from './schema' + +/** + * These tests verify that frontend Zod enum schemas correctly map the integer + * values sent by the .NET API (System.Text.Json default: enums as integers). + * + * Backend enum definitions (source of truth): + * CardStatus: Requested=0, Mailed=1, Active=2, Deactivated=3 + * ApplicationStatus: Unknown=0, Pending=1, Approved=2, Denied=3, UnderReview=4, Cancelled=5 + * IssuanceType: Unknown=0, SummerEbt=1, TanfEbtCard=2, SnapEbtCard=3 + */ +describe('CardStatusSchema', () => { + it.each([ + [0, 'Requested'], + [1, 'Mailed'], + [2, 'Active'], + [3, 'Deactivated'] + ])('maps integer %i to "%s"', (input, expected) => { + expect(CardStatusSchema.parse(input)).toBe(expected) + }) + + it('maps unrecognized integer to "Unknown"', () => { + expect(CardStatusSchema.parse(99)).toBe('Unknown') + }) + + it('passes through string values unchanged', () => { + expect(CardStatusSchema.parse('Active')).toBe('Active') + }) +}) + +describe('ApplicationStatusSchema', () => { + it.each([ + [0, 'Unknown'], + [1, 'Pending'], + [2, 'Approved'], + [3, 'Denied'], + [4, 'UnderReview'], + [5, 'Cancelled'] + ])('maps integer %i to "%s"', (input, expected) => { + expect(ApplicationStatusSchema.parse(input)).toBe(expected) + }) +}) + +describe('IssuanceTypeSchema', () => { + it.each([ + [0, 'Unknown'], + [1, 'SummerEbt'], + [2, 'TanfEbtCard'], + [3, 'SnapEbtCard'] + ])('maps integer %i to "%s"', (input, expected) => { + expect(IssuanceTypeSchema.parse(input)).toBe(expected) + }) +}) describe('formatUsPhone', () => { it('formats a 10-digit string with hyphens', () => { diff --git a/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.test.tsx b/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.test.tsx index 7b02bb4a..8bc0de00 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.test.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.test.tsx @@ -138,33 +138,16 @@ describe('CardStatusDisplay', () => { expect(screen.getByTestId('card-status-badge')).toHaveTextContent('Undeliverable') }) - // ── Replacement eligibility ── + // ── Replacement link ── + // CardStatusDisplay does not render replacement links (ChildCard handles this) - it('shows replacement card link for Lost status', () => { + it('does not render replacement link for Lost status', () => { renderWithStatus('Lost') - expect(screen.getByRole('link')).toHaveTextContent('Request a replacement card') - }) - - it('shows replacement card link for Stolen status', () => { - renderWithStatus('Stolen') - - expect(screen.getByRole('link')).toHaveTextContent('Request a replacement card') - }) - - it('shows replacement card link for Damaged status', () => { - renderWithStatus('Damaged') - - expect(screen.getByRole('link')).toHaveTextContent('Request a replacement card') - }) - - it('does not show replacement card link for DeactivatedByState', () => { - renderWithStatus('DeactivatedByState') - expect(screen.queryByRole('link')).toBeNull() }) - it('does not show replacement card link for Active status', () => { + it('does not render replacement link for Active status', () => { renderWithStatus('Active') expect(screen.queryByRole('link')).toBeNull() diff --git a/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.tsx b/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.tsx index 8bbd1bcc..0321c4ca 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/CardStatusDisplay/CardStatusDisplay.tsx @@ -1,10 +1,9 @@ 'use client' -import Link from 'next/link' import { useTranslation } from 'react-i18next' import type { Application, CardStatus, UiCardStatus } from '../../api' -import { isReplacementEligible, toUiCardStatus } from '../../api' +import { toUiCardStatus } from '../../api' interface CardStatusDisplayProps { application: Application @@ -69,14 +68,7 @@ export function CardStatusDisplay({ application }: CardStatusDisplayProps) { {statusDescription}

- {isReplacementEligible(cardStatus) && ( - - {t('cardTableActionRequestReplacement')} - - )} + {/* Replacement link is rendered by ChildCard, not here */} diff --git a/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.test.tsx b/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.test.tsx index 0101745b..a0ca96c7 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.test.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.test.tsx @@ -83,17 +83,12 @@ describe('CardStatusTimeline', () => { expect(screen.getByText('Deactivated')).toBeInTheDocument() }) - it('shows replacement card link when card is Processed', () => { + it('does not render replacement link (ChildCard handles replacement links)', () => { render( ) - expect(screen.getByRole('link')).toHaveTextContent('Request a replacement card') - }) - - it('does not show replacement card link when card is Active', () => { - render() expect(screen.queryByRole('link')).toBeNull() }) }) diff --git a/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.tsx b/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.tsx index 49af6bac..1e9c828e 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/CardStatusTimeline/CardStatusTimeline.tsx @@ -1,7 +1,6 @@ 'use client' import Image from 'next/image' -import Link from 'next/link' import { useTranslation } from 'react-i18next' import { interpolateDate, type Application, type CardStatus } from '../../api' @@ -99,14 +98,7 @@ export function CardStatusTimeline({ application }: CardStatusTimelineProps) { "After the new card is mailed, it should arrive in around 5–7 days. If it doesn't arrive after two weeks, you can request a replacement card."}

)} - {(cardStatus === 'Requested' || cardStatus === 'Processed') && ( - - {t('cardTableActionRequestReplacement')} - - )} + {/* Replacement link is rendered by ChildCard, not here */} {/* TODO: Active and Deactivated status message fallbacks are placeholders β€” replace with real DC copy once content team updates the Google Sheet. */} {cardStatus === 'Active' && ( diff --git a/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.test.tsx b/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.test.tsx index 20dc5d2b..14dc1c54 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.test.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.test.tsx @@ -161,6 +161,7 @@ describe('ChildCard', () => { it('hides optional fields when not provided', () => { const minimalApplication: Application = { ...mockApplication, + caseNumber: null, benefitIssueDate: null, benefitExpirationDate: null, last4DigitsOfCard: null, @@ -232,6 +233,36 @@ describe('ChildCard', () => { expect(content).not.toHaveAttribute('hidden') }) + it('renders SEBT ID when caseNumber is provided', () => { + renderWithFlags({ + child: mockChild, + application: mockApplication, + id: '0' + }) + + // i18n key: cardTableHeadingSebtId β†’ "DC SUN Bucks ID" (DC) / "Summer EBT ID" (CO) + expect(screen.getByText('DC SUN Bucks ID')).toBeInTheDocument() + expect(screen.getByText('CASE-DC-2026-001')).toBeInTheDocument() + }) + + it('hides SEBT ID when show_case_number flag is off', () => { + renderWithFlags( + { + child: mockChild, + application: mockApplication, + id: '0' + }, + { + flags: { ...TEST_FEATURE_FLAGS, show_case_number: false }, + isLoading: false, + isError: false + } + ) + + expect(screen.queryByText('DC SUN Bucks ID')).not.toBeInTheDocument() + expect(screen.queryByText('CASE-DC-2026-001')).not.toBeInTheDocument() + }) + it('hides card number when show_card_last4 flag is off', () => { renderWithFlags( { diff --git a/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.tsx b/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.tsx index 02ca332b..7c022a65 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/ChildCard/ChildCard.tsx @@ -1,9 +1,12 @@ 'use client' +import Link from 'next/link' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { isWithinCooldownPeriod } from '@/features/cards/utils/cooldown' import { useFeatureFlag } from '@/features/feature-flags' +import { getState } from '@sebt/design-system' import type { Application, Child, IssuanceType } from '../../api' import { formatDate } from '../../api' @@ -19,6 +22,24 @@ function hasDcCardLifecycle(application: Application): boolean { return application.cardRequestedAt != null } +function getReplacementLink(application: Application): string | null { + const { applicationNumber, issuanceType, cardRequestedAt } = application + if (!applicationNumber) return null + + if (isWithinCooldownPeriod(cardRequestedAt)) return null + + const currentState = getState() + const isCoLoaded = issuanceType === 'TanfEbtCard' || issuanceType === 'SnapEbtCard' + + if (isCoLoaded && currentState === 'dc') { + return '/cards/info' + } + + if (isCoLoaded) return null + + return `/cards/replace?app=${encodeURIComponent(applicationNumber)}` +} + interface ChildCardProps { child: Child application: Application @@ -36,12 +57,16 @@ const CARD_TYPE_KEYS: Partial> = { // Keys map to CSV: "S2 - Portal Dashboard - Card Table - {Key}" export function ChildCard({ child, application, id, defaultExpanded = true }: ChildCardProps) { const { t, i18n } = useTranslation('dashboard') + const enableCardReplacement = useFeatureFlag('enable_card_replacement') + const showCaseNumber = useFeatureFlag('show_case_number') const showCardLast4 = useFeatureFlag('show_card_last4') const [isExpanded, setIsExpanded] = useState(defaultExpanded) const childName = `${child.firstName} ${child.lastName}` - const { benefitIssueDate, benefitExpirationDate, last4DigitsOfCard, issuanceType } = application + const { caseNumber, benefitIssueDate, benefitExpirationDate, last4DigitsOfCard, issuanceType } = + application const cardTypeKey = issuanceType ? (CARD_TYPE_KEYS[issuanceType] ?? null) : null + const replacementLink = enableCardReplacement ? getReplacementLink(application) : null return (
@@ -63,6 +88,12 @@ export function ChildCard({ child, application, id, defaultExpanded = true }: Ch data-testid="accordion-content" >
+ {showCaseNumber && caseNumber && ( + <> +
{t('cardTableHeadingSebtId')}
+
{caseNumber}
+ + )} {benefitIssueDate && ( <>
{t('cardTableHeadingIssued')}
@@ -95,6 +126,14 @@ export function ChildCard({ child, application, id, defaultExpanded = true }: Ch )}
+ {replacementLink && ( + + {t('cardTableActionRequestReplacement', 'Request a replacement card')} + + )}
) diff --git a/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.test.tsx b/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.test.tsx index 2042a2d2..09c14342 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.test.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.test.tsx @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { DashboardAlerts } from './DashboardAlerts' +vi.mock('@/features/household', () => ({ + useHouseholdData: () => ({ data: null, isLoading: false, isError: false }) +})) + const mockReplace = vi.fn() let mockSearchParams = new URLSearchParams() @@ -73,6 +77,14 @@ describe('DashboardAlerts', () => { expect(screen.getByText(/address update recorded/i)).toBeInTheDocument() }) + it('renders card replaced alert when flash=card_replaced param is present', () => { + mockSearchParams = new URLSearchParams('flash=card_replaced') + render() + + expect(screen.getByRole('alert')).toBeInTheDocument() + expect(screen.getByText(/replacement card request has been recorded/i)).toBeInTheDocument() + }) + it('combined alert persists after URL params are cleaned', () => { mockSearchParams = new URLSearchParams('addressUpdated=true&cardsRequested=true') const { rerender } = render() diff --git a/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.tsx b/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.tsx index e4f307e7..129960a2 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/DashboardAlerts/DashboardAlerts.tsx @@ -5,23 +5,28 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useHouseholdData } from '@/features/household' + /** * Displays success and warning alerts on the dashboard triggered by URL search params. * Captures alert state on first read, then cleans the params from the URL. * The alert persists because rendering is driven by captured state, not live params. - * Extensible: add new param checks for future alert types (e.g., DC-153 card ordering). + * Card replacement success (flash=card_replaced) checks the household data cache + * for address presence to tailor the alert body, avoiding PII in URL params. */ export function DashboardAlerts() { const { t } = useTranslation('dashboard') const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() + const { data: householdData } = useHouseholdData() // Capture alert state from URL params on first read so the alert // survives the URL cleanup that follows. const [alerts] = useState(() => ({ addressUpdated: searchParams.get('addressUpdated') === 'true', cardsRequested: searchParams.get('cardsRequested') === 'true', + cardReplaced: searchParams.get('flash') === 'card_replaced', addressUpdateFailed: searchParams.get('addressUpdateFailed') === 'true', contactUpdateFailed: searchParams.get('contactUpdateFailed') === 'true', // TODO: Determine trigger logic β€” possibly driven by household data (e.g., address @@ -32,6 +37,7 @@ export function DashboardAlerts() { const hasAlerts = alerts.addressUpdated || alerts.cardsRequested || + alerts.cardReplaced || alerts.addressUpdateFailed || alerts.contactUpdateFailed || alerts.addressVerification @@ -72,6 +78,23 @@ export function DashboardAlerts() { )} + {alerts.cardReplaced && ( + + {householdData?.addressOnFile + ? t( + 'alertCardReplacedBodyWithAddress', + 'New cards usually arrive in your mailbox within 7-10 business days. Check back here in 1-2 business days to see your updated card details.' + ) + : t( + 'alertCardReplacedBody', + 'New cards usually arrive in your mailbox within 7-10 business days.' + )} + + )} + {/* Warning alerts per CO-05 mockup β€” yellow with dark yellow left border. TODO: Wire to actual error flows once state connector persistence is integrated. Currently triggered by URL params for visual verification. */} diff --git a/src/SEBT.Portal.Web/src/features/household/components/EnrolledChildren/EnrolledChildren.tsx b/src/SEBT.Portal.Web/src/features/household/components/EnrolledChildren/EnrolledChildren.tsx index 90e4f298..734a44b8 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/EnrolledChildren/EnrolledChildren.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/EnrolledChildren/EnrolledChildren.tsx @@ -54,6 +54,9 @@ export function EnrolledChildren() { }} application={{ ...c, + applicationNumber: c.applicationId, + caseNumber: c.ebtCaseNumber, + last4DigitsOfCard: c.ebtCardLastFour, applicationStatus: 'Approved' as ApplicationStatus, benefitIssueDate: c.benefitAvailableDate, children: [{ firstName: c.childFirstName, lastName: c.childLastName }], diff --git a/src/SEBT.Portal.Web/src/features/household/components/HouseholdSummary/HouseholdSummary.test.tsx b/src/SEBT.Portal.Web/src/features/household/components/HouseholdSummary/HouseholdSummary.test.tsx index 3dbf1279..47bf3daa 100644 --- a/src/SEBT.Portal.Web/src/features/household/components/HouseholdSummary/HouseholdSummary.test.tsx +++ b/src/SEBT.Portal.Web/src/features/household/components/HouseholdSummary/HouseholdSummary.test.tsx @@ -36,11 +36,11 @@ const defaultMockData: HouseholdData = { summerEbtCases: [mockCase], applications: [mockApplication], addressOnFile: { - streetAddress1: '123 Main Street', - streetAddress2: 'Apt 4B', + streetAddress1: '1350 Pennsylvania Ave NW', + streetAddress2: 'Suite 400', city: 'Washington', state: 'DC', - postalCode: '20001' + postalCode: '20004' } } @@ -118,7 +118,7 @@ describe('HouseholdSummary', () => { it('renders mailing address when provided', () => { render() expect(screen.getByText('Your mailing address')).toBeInTheDocument() - expect(screen.getByText(/123 Main Street/)).toBeInTheDocument() + expect(screen.getByText(/1350 Pennsylvania Ave NW/)).toBeInTheDocument() }) it('renders change mailing address link', () => { @@ -131,6 +131,7 @@ describe('HouseholdSummary', () => { mockReturnData = { ...defaultMockData, addressOnFile: null } render() expect(screen.queryByText('Your mailing address')).not.toBeInTheDocument() + expect(screen.queryByText(/1350 Pennsylvania Ave NW/)).not.toBeInTheDocument() }) it('renders preferred contact with email', () => { diff --git a/src/SEBT.Portal.Web/src/features/household/testing/fixtures.ts b/src/SEBT.Portal.Web/src/features/household/testing/fixtures.ts index afcecbdd..c5b91613 100644 --- a/src/SEBT.Portal.Web/src/features/household/testing/fixtures.ts +++ b/src/SEBT.Portal.Web/src/features/household/testing/fixtures.ts @@ -51,11 +51,11 @@ export function createMockApplication(overrides?: Partial): Applica export function createMockAddress(overrides?: Partial
): Address { return { - streetAddress1: '123 Main Street', - streetAddress2: 'Apt 4B', + streetAddress1: '1350 Pennsylvania Ave NW', + streetAddress2: 'Suite 400', city: 'Washington', state: 'DC', - postalCode: '20001', + postalCode: '20004', ...overrides } } diff --git a/src/SEBT.Portal.Web/src/mocks/handlers.ts b/src/SEBT.Portal.Web/src/mocks/handlers.ts index 39833cff..2b8ee343 100644 --- a/src/SEBT.Portal.Web/src/mocks/handlers.ts +++ b/src/SEBT.Portal.Web/src/mocks/handlers.ts @@ -95,11 +95,11 @@ export const TEST_HOUSEHOLD_DATA = { } ], addressOnFile: { - streetAddress1: '123 Main Street', - streetAddress2: 'Apt 4B', + streetAddress1: '1350 Pennsylvania Ave NW', + streetAddress2: 'Suite 400', city: 'Washington', state: 'DC', - postalCode: '20001' + postalCode: '20004' }, userProfile: { firstName: 'Maria', @@ -320,5 +320,12 @@ export const handlers = [ // reflect the real contract (validation errors, response body if not 204, etc.) http.put('/api/household/address', () => { return new HttpResponse(null, { status: 204 }) + }), + + // Card replacement endpoint (stub β€” no real persistence yet) + // TODO: When state connector persistence is wired up, update this handler to + // reflect the real contract (cooldown validation errors, etc.) + http.post('/api/household/cards/replace', () => { + return new HttpResponse(null, { status: 204 }) }) ] diff --git a/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs b/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs index 2cf588ad..77911f4f 100644 --- a/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs +++ b/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs @@ -13,6 +13,7 @@ using SEBT.Portal.Core.Services; using SEBT.Portal.Core.Utilities; using SEBT.Portal.Kernel; +using SEBT.Portal.Kernel.Results; using SEBT.Portal.UseCases.Household; namespace SEBT.Portal.Tests.Unit.Controllers; @@ -452,6 +453,75 @@ public async Task GetHouseholdData_ExtractsEmailFromIdentityName_WhenOtherClaims await repositoryMock.Received(1).GetHouseholdByIdentifierAsync(Arg.Is(id => id.Type == PreferredHouseholdIdType.Email && id.Value == EmailNormalizer.Normalize(email)), Arg.Any(), Arg.Any(), Arg.Any()); } + // --- RequestCardReplacement tests --- + + [Fact] + public async Task RequestCardReplacement_ReturnsNoContent_WhenHandlerSucceeds() + { + // Arrange + SetupAuthenticatedUser("user@example.com", ial: "1plus"); + var request = new RequestCardReplacementRequest + { + ApplicationNumbers = new List { "APP-001" } + }; + + var commandHandler = Substitute.For>(); + commandHandler.Handle(Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + // Act + var result = await _controller.RequestCardReplacement(request, commandHandler); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task RequestCardReplacement_ReturnsBadRequest_WhenValidationFails() + { + // Arrange + SetupAuthenticatedUser("user@example.com", ial: "1plus"); + var request = new RequestCardReplacementRequest + { + ApplicationNumbers = new List { "APP-001" } + }; + + var commandHandler = Substitute.For>(); + commandHandler.Handle(Arg.Any(), Arg.Any()) + .Returns(Result.ValidationFailed("ApplicationNumbers", "Application was requested within the last 14 days.")); + + // Act + var result = await _controller.RequestCardReplacement(request, commandHandler); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(400, objectResult.StatusCode); + } + + [Fact] + public async Task RequestCardReplacement_MapsRequestToCommand() + { + // Arrange + SetupAuthenticatedUser("user@example.com", ial: "1plus"); + var request = new RequestCardReplacementRequest + { + ApplicationNumbers = new List { "APP-001", "APP-002" } + }; + + RequestCardReplacementCommand? capturedCommand = null; + var commandHandler = Substitute.For>(); + commandHandler.Handle(Arg.Do(cmd => capturedCommand = cmd), Arg.Any()) + .Returns(Result.Success()); + + // Act + await _controller.RequestCardReplacement(request, commandHandler); + + // Assert + Assert.NotNull(capturedCommand); + Assert.Equal(new List { "APP-001", "APP-002" }, capturedCommand.ApplicationNumbers); + Assert.NotNull(capturedCommand.User); + } + [Fact] public async Task GetHouseholdData_WhenIdProofingStatusIsExpired_DoesNotIncludeAddress() { diff --git a/test/SEBT.Portal.Tests/Unit/UseCases/DependenciesTests.cs b/test/SEBT.Portal.Tests/Unit/UseCases/DependenciesTests.cs new file mode 100644 index 00000000..0c91e5ea --- /dev/null +++ b/test/SEBT.Portal.Tests/Unit/UseCases/DependenciesTests.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using SEBT.Portal.Core.Repositories; +using SEBT.Portal.Core.Services; +using SEBT.Portal.Kernel; +using SEBT.Portal.UseCases; +using SEBT.Portal.UseCases.Household; + +namespace SEBT.Portal.Tests.Unit.UseCases; + +/// +/// Verifies that AddUseCases() registers all command and query handlers in the DI container. +/// +public class DependenciesTests +{ + private static ServiceProvider BuildProviderWithUseCases() + { + var services = new ServiceCollection(); + services.AddUseCases(); + + // Stub infrastructure dependencies that handlers need at construction time + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + + return services.BuildServiceProvider(); + } + + [Fact] + public void AddUseCases_RegistersRequestCardReplacementCommandHandler() + { + using var provider = BuildProviderWithUseCases(); + + var handler = provider.GetService>(); + + Assert.NotNull(handler); + Assert.IsType(handler); + } +} diff --git a/test/SEBT.Portal.Tests/Unit/UseCases/Household/RequestCardReplacementCommandHandlerTests.cs b/test/SEBT.Portal.Tests/Unit/UseCases/Household/RequestCardReplacementCommandHandlerTests.cs new file mode 100644 index 00000000..203adc80 --- /dev/null +++ b/test/SEBT.Portal.Tests/Unit/UseCases/Household/RequestCardReplacementCommandHandlerTests.cs @@ -0,0 +1,383 @@ +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SEBT.Portal.Core.Models; +using SEBT.Portal.Core.Models.Auth; +using SEBT.Portal.Core.Models.Household; +using SEBT.Portal.Core.Repositories; +using SEBT.Portal.Core.Services; +using SEBT.Portal.Core.Utilities; +using SEBT.Portal.Kernel; +using SEBT.Portal.Kernel.Results; +using SEBT.Portal.UseCases.Household; + +namespace SEBT.Portal.Tests.Unit.UseCases.Household; + +public class RequestCardReplacementCommandHandlerTests +{ + private readonly IValidator _validator = + new DataAnnotationsValidator(null!); + private readonly IHouseholdIdentifierResolver _resolver = + Substitute.For(); + private readonly IHouseholdRepository _repository = + Substitute.For(); + private readonly NullLogger _logger = + NullLogger.Instance; + + private RequestCardReplacementCommandHandler CreateHandler(TimeProvider? timeProvider = null) => + new(_validator, _resolver, _repository, timeProvider ?? TimeProvider.System, _logger); + + private static ClaimsPrincipal CreateUser(string email, string? ialClaim = null) + { + var claims = new List { new(ClaimTypes.Email, email) }; + if (ialClaim != null) + claims.Add(new Claim(JwtClaimTypes.Ial, ialClaim)); + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } + + private static RequestCardReplacementCommand CreateValidCommand( + ClaimsPrincipal? user = null, + List? applicationNumbers = null) => + new() + { + User = user ?? CreateUser("user@example.com"), + ApplicationNumbers = applicationNumbers ?? new List { "APP-2026-001" } + }; + + private static HouseholdData CreateHouseholdWithApplications(params Application[] applications) => + new() + { + Applications = applications.ToList() + }; + + private void SetupResolverSuccess() + { + _resolver.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(HouseholdIdentifier.Email(EmailNormalizer.Normalize("user@example.com"))); + } + + private void SetupRepositoryReturns(HouseholdData householdData) + { + _repository.GetHouseholdByIdentifierAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(householdData); + } + + // --- Validation tests --- + + [Fact] + public async Task Handle_ReturnsValidationFailed_WhenApplicationNumbersIsEmpty() + { + var handler = CreateHandler(); + var command = CreateValidCommand(applicationNumbers: new List()); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + // --- Authorization tests --- + + [Fact] + public async Task Handle_ReturnsUnauthorized_WhenHouseholdIdentifierCannotBeResolved() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + + _resolver.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns((HouseholdIdentifier?)null); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + [Fact] + public async Task Handle_DoesNotCallResolver_WhenValidationFails() + { + var handler = CreateHandler(); + var command = CreateValidCommand(applicationNumbers: new List()); + + await handler.Handle(command, CancellationToken.None); + + await _resolver.DidNotReceive() + .ResolveAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsPreconditionFailed_WhenHouseholdDataNotFound() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + SetupResolverSuccess(); + // Repository returns null by default (NSubstitute unconfigured) + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + // --- Cooldown tests --- + + [Fact] + public async Task Handle_ReturnsValidationFailed_WhenApplicationIsWithinCooldownPeriod() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Requested, + CardRequestedAt = DateTime.UtcNow.AddDays(-3) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + [Fact] + public async Task Handle_ReturnsValidationFailed_WhenMailedCardIsStillWithinCooldown() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Mailed, + CardRequestedAt = DateTime.UtcNow.AddDays(-10) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + [Fact] + public async Task Handle_ReturnsSuccess_WhenApplicationIsOutsideCooldownPeriod() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Active, + CardRequestedAt = DateTime.UtcNow.AddDays(-30) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.IsType(result); + } + + [Fact] + public async Task Handle_ReturnsSuccess_WhenCardRequestedAtIsNull() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Active, + CardRequestedAt = null + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Handle_ReturnsValidationFailed_WhenApplicationNumberNotInHousehold() + { + var handler = CreateHandler(); + var command = CreateValidCommand(applicationNumbers: new List { "APP-UNKNOWN" }); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Active, + CardRequestedAt = DateTime.UtcNow.AddDays(-30) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + // --- Success tests --- + + [Fact] + public async Task Handle_ReturnsSuccess_WhenValidCommandAndNoActiveCooldown() + { + var handler = CreateHandler(); + var command = CreateValidCommand(applicationNumbers: new List { "APP-001", "APP-002" }); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-001", + CardStatus = CardStatus.Active, + CardRequestedAt = DateTime.UtcNow.AddDays(-30) + }, + new Application + { + ApplicationNumber = "APP-002", + CardStatus = CardStatus.Active, + CardRequestedAt = DateTime.UtcNow.AddDays(-20) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.IsType(result); + } + + // --- Cancellation token propagation --- + + [Fact] + public async Task Handle_PassesCancellationTokenToResolver() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + var cts = new CancellationTokenSource(); + var token = cts.Token; + + _resolver.ResolveAsync(Arg.Any(), token) + .Returns(HouseholdIdentifier.Email(EmailNormalizer.Normalize("user@example.com"))); + + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardRequestedAt = DateTime.UtcNow.AddDays(-30) + } + )); + + await handler.Handle(command, token); + + await _resolver.Received(1).ResolveAsync(Arg.Any(), token); + } + + // --- IAL propagation tests --- + + [Fact] + public async Task Handle_PassesUserIalLevelToRepository() + { + var handler = CreateHandler(); + var user = CreateUser("user@example.com", ialClaim: "1plus"); + var command = CreateValidCommand(user: user); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardRequestedAt = DateTime.UtcNow.AddDays(-30) + } + )); + + await handler.Handle(command, CancellationToken.None); + + await _repository.Received(1).GetHouseholdByIdentifierAsync( + Arg.Any(), + Arg.Any(), + UserIalLevel.IAL1plus, + Arg.Any()); + } + + [Fact] + public async Task Handle_PassesIalNone_WhenNoIalClaim() + { + var handler = CreateHandler(); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardRequestedAt = DateTime.UtcNow.AddDays(-30) + } + )); + + await handler.Handle(command, CancellationToken.None); + + await _repository.Received(1).GetHouseholdByIdentifierAsync( + Arg.Any(), + Arg.Any(), + UserIalLevel.None, + Arg.Any()); + } + + // --- Cooldown boundary tests (using FakeTimeProvider) --- + + [Fact] + public async Task Handle_ReturnsValidationFailed_WhenCardRequestedExactly13DaysAgo() + { + var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)); + var handler = CreateHandler(fakeTime); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Requested, + CardRequestedAt = new DateTime(2026, 6, 2, 12, 0, 0, DateTimeKind.Utc) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.IsType(result); + } + + [Fact] + public async Task Handle_ReturnsSuccess_WhenCardRequestedExactly14DaysAgo() + { + var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)); + var handler = CreateHandler(fakeTime); + var command = CreateValidCommand(); + SetupResolverSuccess(); + SetupRepositoryReturns(CreateHouseholdWithApplications( + new Application + { + ApplicationNumber = "APP-2026-001", + CardStatus = CardStatus.Active, + CardRequestedAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc) + } + )); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.IsType(result); + } +}