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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions frontend/src/__tests__/plans-range-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { openNewPlanModal, closePlanModal, openAddPurchasesModal } from '../plans';
import type { ToastOptions } from '../toast';
import * as api from '../api';

jest.mock('../api', () => ({
getPlans: jest.fn(),
Expand Down Expand Up @@ -458,7 +459,7 @@ describe('Add Purchases modal: inline range validation (#771)', () => {
ppList.id = 'planned-purchases-list';
document.body.replaceChildren(plansList, ppList);

await openAddPurchasesModal('plan-xyz', 'My Test Plan');
await openAddPurchasesModal('plan-xyz', 'My Test Plan', 3);
});

function countInput(): HTMLInputElement {
Expand Down Expand Up @@ -623,14 +624,11 @@ describe('Add Purchases modal: inline range validation (#771)', () => {
/**
* The start-date input must have its `min` attribute set to today's ISO date
* so the browser date-picker blocks past dates while still allowing today.
* The default value stays tomorrow (to encourage scheduling ahead), but min
* is today so a same-day purchase remains possible.
* The suggested value allows the plan's notice period, but min stays today.
*/
describe('Add Purchases modal: start-date picker enforces future-only dates (QA 5.6)', () => {
// Local-calendar ISO YYYY-MM-DD. Mirrors plans.ts toLocalDateInputValue.
// The production code must derive both `value` and `min` from local-time
// components (not toISOString, which is UTC) so a user west of UTC at
// 6pm local doesn't see "today" greyed out in the picker.
// The picker minimum stays local even though scheduled wire dates are UTC.
function toLocalDateInputValue(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
Expand All @@ -646,7 +644,7 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA
ppList.id = 'planned-purchases-list';
document.body.replaceChildren(plansList, ppList);

await openAddPurchasesModal('plan-xyz', 'My Test Plan');
await openAddPurchasesModal('plan-xyz', 'My Test Plan', 3);
});

it('sets min to today on the start-date input after the modal opens', () => {
Expand All @@ -656,11 +654,10 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA
expect(input.min).toBe(toLocalDateInputValue(new Date()));
});

it('keeps the default value set to tomorrow, not today', () => {
it('suggests a UTC date beyond the configured notice window', () => {
const input = document.getElementById('add-purchases-start-date') as HTMLInputElement;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
expect(input.value).toBe(toLocalDateInputValue(tomorrow));
const now = new Date();
expect(input.value).toBe(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 5)).toISOString().slice(0, 10));
});

it('uses the local calendar day, not UTC, so users west of UTC can still pick today', () => {
Expand All @@ -676,3 +673,56 @@ describe('Add Purchases modal: start-date picker enforces future-only dates (QA
expect(input.min).toBe(localDay);
});
});

describe('Add Purchases configured notice lead time', () => {
beforeEach(() => {
jest.clearAllMocks();
document.body.replaceChildren();
});
afterEach(() => jest.useRealTimers());

it.each([
[0, '2026-10-15T23:59:00Z', '2026-10-17'],
[1, '2026-12-31T23:59:00Z', '2027-01-03'],
[3, '2026-10-15T23:59:00Z', '2026-10-20'],
[30, '2026-01-31T23:59:00Z', '2026-03-04'],
[3, '2026-10-14T23:00:00-07:00', '2026-10-20'],
[3, '2026-10-15T00:01:00+14:00', '2026-10-19'],
[3, '2026-03-07T23:59:00-05:00', '2026-03-13'],
[3, '2026-10-24T23:59:00+02:00', '2026-10-29'],
])('uses N=%s at %s to suggest %s', async (notice, now, expected) => {
jest.useFakeTimers({ now: new Date(now) });
await openAddPurchasesModal('plan-notice', 'Notice plan', notice);
expect((document.getElementById('add-purchases-start-date') as HTMLInputElement).value).toBe(expected);
expect(document.getElementById('add-purchases-date-help')?.textContent).toContain(`${notice}-day notification window`);
expect(document.querySelector('button[type="submit"]')).not.toBeDisabled();
});

it.each([undefined, NaN, Infinity, -1, 31, 1.5])('fails visibly for invalid notice %s', async notice => {
await openAddPurchasesModal('plan-notice', 'Notice plan', notice as number);
expect(document.getElementById('add-purchases-error')?.textContent).toContain('missing or invalid');
const count = document.getElementById('add-purchases-count') as HTMLInputElement;
count.dispatchEvent(new Event('input'));
expect(document.querySelector('button[type="submit"]')).toBeDisabled();
document.getElementById('add-purchases-form')?.dispatchEvent(new Event('submit', { cancelable: true }));
expect(api.createPlannedPurchases).not.toHaveBeenCalled();
});

it('preserves an earlier selected date and submits it while showing a warning', async () => {
jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') });
(api.createPlannedPurchases as jest.Mock).mockResolvedValue({});
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
await openAddPurchasesModal('plan-notice', 'Notice plan', 3);
const date = document.getElementById('add-purchases-start-date') as HTMLInputElement;
const minimum = date.min;
date.value = '2026-10-16';
date.dispatchEvent(new Event('input'));
expect(date.value).toBe('2026-10-16');
expect(date.min).toBe(minimum);
expect(document.getElementById('add-purchases-date-warning')?.textContent).toContain('may leave too little notice');
expect(document.querySelector('button[type="submit"]')).not.toBeDisabled();
document.getElementById('add-purchases-form')?.dispatchEvent(new Event('submit', { cancelable: true }));
expect(api.createPlannedPurchases).toHaveBeenCalledWith('plan-notice', 1, '2026-10-16');
await Promise.resolve();
});
});
8 changes: 8 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2420,6 +2420,7 @@ describe('Plans Module', () => {
name: 'Test Plan',
enabled: true,
auto_purchase: false,
notification_days_before: 3,
services: {}
}
]
Expand All @@ -2434,6 +2435,13 @@ describe('Plans Module', () => {
await new Promise(resolve => setTimeout(resolve, 50));
});

test('default date leaves the displayed plan notification window intact', () => {
const now = new Date();
const expected = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 5));
const input = document.getElementById('add-purchases-start-date') as HTMLInputElement;
expect(input.value).toBe(expected.toISOString().slice(0, 10));
});

test('cancel button closes modal', async () => {
const cancelBtn = document.getElementById('add-purchases-cancel') as HTMLButtonElement;
cancelBtn?.click();
Expand Down
53 changes: 34 additions & 19 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan
` : ''}
</div>
<div class="plan-actions">
${canAddPurchases && !isUnassigned ? `<button data-action="add-purchases" data-id="${escapeHtmlAttr(plan.id)}" data-name="${escapeHtmlAttr(plan.name)}" class="primary">Add Purchases</button>` : ''}
${canAddPurchases && !isUnassigned ? `<button data-action="add-purchases" data-id="${escapeHtmlAttr(plan.id)}" data-name="${escapeHtmlAttr(plan.name)}" data-notification-days="${escapeHtmlAttr(String(plan.notification_days_before))}" class="primary">Add Purchases</button>` : ''}
${canManagePlan && !isUnassigned ? `<button data-action="edit-plan" data-id="${escapeHtmlAttr(plan.id)}">Edit</button>` : ''}
<button data-action="view-history" data-id="${escapeHtmlAttr(plan.id)}" class="secondary">History</button>
${canDeletePlan ? `<button data-action="delete-plan" data-id="${escapeHtmlAttr(plan.id)}" class="danger">Delete</button>` : ''}
Expand Down Expand Up @@ -1240,7 +1240,7 @@ function renderPlans(plans: LocalPlan[]): void {
toggle.addEventListener('change', () => void togglePlan(toggle.dataset['id'] || '', toggle.checked));
});
container.querySelectorAll<HTMLButtonElement>('[data-action="add-purchases"]').forEach(btn => {
btn.addEventListener('click', () => void openAddPurchasesModal(btn.dataset['id'] || '', btn.dataset['name'] || ''));
btn.addEventListener('click', () => void openAddPurchasesModal(btn.dataset['id'] || '', btn.dataset['name'] || '', Number(btn.dataset['notificationDays'])));
});
container.querySelectorAll<HTMLButtonElement>('[data-action="edit-plan"]').forEach(btn => {
btn.addEventListener('click', () => void editPlan(btn.dataset['id'] || ''));
Expand Down Expand Up @@ -2206,7 +2206,7 @@ export function closePurchaseModal(): void {
/**
* Open modal to add planned purchases for a plan
*/
export async function openAddPurchasesModal(planId: string, planName: string): Promise<void> {
export async function openAddPurchasesModal(planId: string, planName: string, notificationDays: number): Promise<void> {
// Remove existing modal if present
document.getElementById('add-purchases-modal')?.remove();

Expand All @@ -2227,9 +2227,10 @@ export async function openAddPurchasesModal(planId: string, planName: string): P
<p class="help-text">How many purchase steps to schedule (based on the plan's ramp schedule settings)</p>

<label>Start Date:
<input type="date" id="add-purchases-start-date" required>
<input type="date" id="add-purchases-start-date" aria-describedby="add-purchases-date-help add-purchases-date-warning" required>
</label>
<p class="help-text">When to schedule the first purchase. Subsequent purchases follow the plan's interval.</p>
<p id="add-purchases-date-help" class="help-text"></p>
<p id="add-purchases-date-warning" class="help-text" role="status"></p>

<div id="add-purchases-error" class="error-message hidden"></div>

Expand All @@ -2243,23 +2244,36 @@ export async function openAddPurchasesModal(planId: string, planName: string): P
`;
document.body.appendChild(modal);

// Set default start date to tomorrow.
//
// Date inputs use the user's *local* calendar day, so we must build the
// ISO string from local components (toISOString returns UTC). For a user
// west of UTC on the evening of day D, `new Date().toISOString()` returns
// D+1's calendar date, which would make `min` reject "today" in the
// picker and contradict the QA 5.6 promise that same-day purchases stay
// selectable. Mirrors isPlanOverdue's local-midnight pattern above.
const startDateInput = document.getElementById('add-purchases-start-date') as HTMLInputElement;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
startDateInput.value = toLocalDateInputValue(tomorrow);
startDateInput.min = toLocalDateInputValue(new Date());
const now = new Date();
const validNotice = Number.isInteger(notificationDays) && notificationDays >= 0 && notificationDays <= 30;
startDateInput.min = toLocalDateInputValue(now);
if (validNotice) {
// The API parses date-only values at UTC midnight. The extra calendar
// day avoids opening just before the notification/veto overlap ends.
const suggested = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + notificationDays + 2)).toISOString().slice(0, 10);
startDateInput.value = suggested;
document.getElementById('add-purchases-date-help')!.textContent = `This plan has a ${notificationDays}-day notification window. Dates use midnight UTC; subsequent purchases follow the plan's interval.`;
const updateDateWarning = (): void => {
const selected = Date.parse(`${startDateInput.value}T00:00:00Z`);
document.getElementById('add-purchases-date-warning')!.textContent = selected < Date.now() + notificationDays * 24 * 60 * 60 * 1000
? `This date may leave too little notice for recommendations to attach. Suggested start date: ${suggested}.`
: '';
};
startDateInput.addEventListener('input', updateDateWarning);
updateDateWarning();
} else {
const error = document.getElementById('add-purchases-error')!;
error.textContent = 'The plan notification window is missing or invalid. Reload the plan before scheduling purchases.';
error.classList.remove('hidden');
}

// Add event listeners
document.getElementById('add-purchases-cancel')?.addEventListener('click', closeAddPurchasesModal);
document.getElementById('add-purchases-form')?.addEventListener('submit', (e) => void handleAddPurchases(e));
document.getElementById('add-purchases-form')?.addEventListener('submit', (e) => {
if (validNotice) void handleAddPurchases(e);
else e.preventDefault();
});

// Inline range validation on the count field: surfaces a
// "Must be a whole number between 1 and 52" message under the input
Expand All @@ -2276,10 +2290,11 @@ export async function openAddPurchasesModal(planId: string, planName: string): P
const submitBtn = modal.querySelector<HTMLButtonElement>('button[type="submit"]');
if (countInput && submitBtn) {
const syncSubmitBtn = (): void => {
submitBtn.disabled = countInput.getAttribute('aria-invalid') === 'true';
submitBtn.disabled = !validNotice || countInput.getAttribute('aria-invalid') === 'true';
};
countInput.addEventListener('input', syncSubmitBtn);
countInput.addEventListener('blur', syncSubmitBtn);
syncSubmitBtn();
}

// Engage focus trap + Escape handler. The modal element itself is
Expand Down
64 changes: 64 additions & 0 deletions frontend/tests-e2e/plan-purchase-notice.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import { mockApi, seedAuth } from './fixtures/recs';

const cases = [
{ zone: 'UTC', now: '2026-10-15T23:59:00Z', suggested: '2026-10-20', selected: '2026-10-16' },
{ zone: 'America/Los_Angeles', now: '2026-03-08T07:59:00Z', suggested: '2026-03-13', selected: '2026-03-09' },
{ zone: 'Europe/Berlin', now: '2026-10-24T23:59:00Z', suggested: '2026-10-29', selected: '2026-10-26' },
];

for (const scenario of cases) {
test.describe(scenario.zone, () => {
test.use({ timezoneId: scenario.zone });
test('suggests notice lead time and preserves an earlier user date (mock API)', async ({ page }, testInfo) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('requestfailed', request => errors.push(request.url()));
page.on('response', response => { if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); });
await page.route('**/*', route => new URL(route.request().url()).hostname === '127.0.0.1'
? route.fallback() : route.abort());
await page.clock.setFixedTime(new Date(scenario.now));
await seedAuth(page);
await mockApi(page);
const submitted: unknown[] = [];
await page.route('**/api/plans**', async route => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/purchases') && route.request().method() === 'POST') {
submitted.push(route.request().postDataJSON());
await route.fulfill({ json: { created: 1 } });
} else if (path.endsWith('/accounts')) {
await route.fulfill({ json: [] });
} else {
await route.fulfill({ json: { plans: [{ id: 'plan-notice', name: 'Notice plan', enabled: true,
auto_purchase: false, notification_days_before: 3,
services: { ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'no-upfront', coverage: 80 } },
ramp_schedule: { current_step: 0, total_steps: 4, interval_days: 7, percent_per_step: 25 },
}] } });
}
});
await page.route('**/api/purchases/planned', route => route.fulfill({ json: { purchases: [] } }));
await page.goto('/plans');
const scripts = await page.locator('script[src]').evaluateAll(elements => elements.map(element => (element as HTMLScriptElement).src));
expect(scripts.some(source => /app\.[a-f0-9]+\.js/.test(source))).toBe(true);
await page.locator('[data-action="add-purchases"][data-id="plan-notice"]').click();
const modal = page.locator('#add-purchases-modal');
const date = page.locator('#add-purchases-start-date');
await expect(date).toHaveValue(scenario.suggested);
await expect(page.locator('#add-purchases-date-help')).toContainText('3-day notification window');
await expect(page.locator('#add-purchases-date-help')).toContainText('midnight UTC');
const minimum = await date.getAttribute('min');
const suggested = await date.inputValue();
await date.fill(scenario.selected);
await expect(date).toHaveValue(scenario.selected);
await expect(date).toHaveAttribute('min', minimum!);
await expect(page.locator('#add-purchases-date-warning')).toContainText('may leave too little notice');
await page.screenshot({ path: testInfo.outputPath('notice-modal.png'), fullPage: true });
await modal.getByRole('button', { name: 'Add Purchases', exact: true }).click();
await expect(modal).toHaveCount(0);
expect(submitted).toEqual([{ count: 1, start_date: scenario.selected }]);
expect(errors).toEqual([]);
await testInfo.attach('notice-date-receipt', { body: JSON.stringify({ ...scenario, suggested, minimum, notice: 3, submitted, scripts }), contentType: 'application/json' });
});
});
}
Loading