fix(agent-world): send RFC3339 job deadline to avoid HTTP 400 (#3835)

This commit is contained in:
Cyrus Gray
2026-06-19 09:08:26 -07:00
committed by GitHub
parent 84eea193cc
commit a05be91e24
16 changed files with 129 additions and 1 deletions
@@ -620,3 +620,99 @@ describe('Jobs write actions', () => {
});
});
});
// ── Proposal deadline normalization ───────────────────────────────────────────
describe('Proposal deadline normalization', () => {
// Helper: open the Post Job modal and fill required fields.
async function openPostJobModal(user: ReturnType<typeof userEvent.setup>) {
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any);
vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [], count: 0 });
render(<JobsSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /post a job/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /post a job/i }));
// Fill required fields: title and budget amount.
await user.type(screen.getByPlaceholderText(/build a solana/i), 'Test Job Title');
await user.type(screen.getByPlaceholderText('500'), '100');
}
test('date-only input is normalized to RFC3339 end-of-day UTC before submit', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any);
await openPostJobModal(user);
// Use fireEvent to set the date input — userEvent doesn't drive date pickers.
// The modal has exactly one input[type="date"] (Proposal Deadline).
const { fireEvent } = await import('@testing-library/react');
const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement;
fireEvent.change(dateInput, { target: { value: '2099-12-31' } });
await user.click(screen.getByRole('button', { name: /post job/i }));
await waitFor(() => {
expect(vi.mocked(apiClient.jobsWrite.create)).toHaveBeenCalledWith(
expect.objectContaining({ proposalDeadline: '2099-12-31T23:59:59Z' })
);
});
});
test('empty deadline is omitted (sent as undefined)', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any);
await openPostJobModal(user);
// Leave the deadline blank and submit.
await user.click(screen.getByRole('button', { name: /post job/i }));
await waitFor(() => {
expect(vi.mocked(apiClient.jobsWrite.create)).toHaveBeenCalledWith(
expect.objectContaining({ proposalDeadline: undefined })
);
});
});
test('past deadline shows validation error and does not call create', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any);
await openPostJobModal(user);
const { fireEvent } = await import('@testing-library/react');
const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement;
fireEvent.change(dateInput, { target: { value: '2020-01-01' } });
await user.click(screen.getByRole('button', { name: /post job/i }));
await waitFor(() => {
expect(screen.getByText(/proposal deadline must be in the future/i)).toBeInTheDocument();
});
expect(vi.mocked(apiClient.jobsWrite.create)).not.toHaveBeenCalled();
});
test('current-day deadline shows validation error and does not call create', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any);
await openPostJobModal(user);
// Build today's date in YYYY-MM-DD using local calendar (same logic as
// the component) to ensure the guard fires regardless of UTC offset.
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const { fireEvent } = await import('@testing-library/react');
const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement;
fireEvent.change(dateInput, { target: { value: today } });
await user.click(screen.getByRole('button', { name: /post job/i }));
await waitFor(() => {
expect(screen.getByText(/proposal deadline must be in the future/i)).toBeInTheDocument();
});
expect(vi.mocked(apiClient.jobsWrite.create)).not.toHaveBeenCalled();
});
});
+19 -1
View File
@@ -25,6 +25,7 @@ import {
type Proposal,
type ProposalCreateParams,
} from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import { explorerTxUrl } from '../hooks/useX402Buy';
@@ -187,6 +188,7 @@ function ClientAvatar({ avatarUrl, displayName }: { avatarUrl?: string; displayN
// ── PostJobModal ──────────────────────────────────────────────────────────────
function PostJobModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { t } = useT();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('');
@@ -202,6 +204,22 @@ function PostJobModal({ onClose, onCreated }: { onClose: () => void; onCreated:
if (!title.trim() || !budgetAmount.trim()) return;
setSubmitting(true);
setError(null);
// <input type="date"> yields "YYYY-MM-DD" but the server requires RFC3339.
// Pin to end-of-day UTC, consistent with BountiesSection.
const deadlineDate = proposalDeadline.trim();
const deadlineIso = deadlineDate ? `${deadlineDate}T23:59:59Z` : undefined;
// Reject today and past dates by comparing the raw date string against the
// local calendar date — end-of-day UTC would otherwise let "today" pass
// until midnight UTC.
if (deadlineDate) {
const now = new Date();
const todayLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
if (deadlineDate <= todayLocal) {
setError(t('agentWorld.jobs.deadlineFuture', 'Proposal deadline must be in the future'));
setSubmitting(false);
return;
}
}
const params: JobCreateParams = {
title: title.trim(),
description: description.trim() || undefined,
@@ -214,7 +232,7 @@ function PostJobModal({ onClose, onCreated }: { onClose: () => void; onCreated:
: undefined,
budgetAmount: budgetAmount.trim(),
budgetAsset: budgetAsset.trim() || 'USDC',
proposalDeadline: proposalDeadline || undefined,
proposalDeadline: deadlineIso,
};
try {
await apiClient.jobsWrite.create(params);
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'الملفات الشخصية',
'agentWorld.marketplace': 'السوق',
'agentWorld.messaging': 'الرسائل',
'agentWorld.jobs.deadlineFuture': 'يجب أن يكون الموعد النهائي للمقترح في المستقبل',
'nav.avatarMenu.account': 'الحساب',
'nav.avatarMenu.billing': 'الفواتير',
'nav.avatarMenu.rewards': 'المكافآت',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'প্রোফাইল',
'agentWorld.marketplace': 'মার্কেটপ্লেস',
'agentWorld.messaging': 'বার্তা',
'agentWorld.jobs.deadlineFuture': 'প্রস্তাবের সময়সীমা ভবিষ্যতে হতে হবে',
'nav.avatarMenu.account': 'অ্যাকাউন্ট',
'nav.avatarMenu.billing': 'বিলিং',
'nav.avatarMenu.rewards': 'পুরস্কার',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profile',
'agentWorld.marketplace': 'Marktplatz',
'agentWorld.messaging': 'Nachrichten',
'agentWorld.jobs.deadlineFuture': 'Die Angebotsfrist muss in der Zukunft liegen',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Abrechnung',
'nav.avatarMenu.rewards': 'Prämien',
+1
View File
@@ -37,6 +37,7 @@ const en: TranslationMap = {
'agentWorld.profiles': 'Profiles',
'agentWorld.marketplace': 'Marketplace',
'agentWorld.messaging': 'Messages',
'agentWorld.jobs.deadlineFuture': 'Proposal deadline must be in the future',
// Agent World — Settings section UI
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Billing',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Perfiles',
'agentWorld.marketplace': 'Mercado',
'agentWorld.messaging': 'Mensajes',
'agentWorld.jobs.deadlineFuture': 'La fecha límite de la propuesta debe ser en el futuro',
'nav.avatarMenu.account': 'Cuenta',
'nav.avatarMenu.billing': 'Facturación',
'nav.avatarMenu.rewards': 'Recompensas',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profils',
'agentWorld.marketplace': 'Marché',
'agentWorld.messaging': 'Messages',
'agentWorld.jobs.deadlineFuture': 'La date limite de la proposition doit être dans le futur',
'nav.avatarMenu.account': 'Compte',
'nav.avatarMenu.billing': 'Facturation',
'nav.avatarMenu.rewards': 'Récompenses',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'प्रोफ़ाइल',
'agentWorld.marketplace': 'मार्केटप्लेस',
'agentWorld.messaging': 'संदेश',
'agentWorld.jobs.deadlineFuture': 'प्रस्ताव की समय सीमा भविष्य में होनी चाहिए',
'nav.avatarMenu.account': 'खाता',
'nav.avatarMenu.billing': 'बिलिंग',
'nav.avatarMenu.rewards': 'रिवॉर्ड',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profil',
'agentWorld.marketplace': 'Pasar',
'agentWorld.messaging': 'Pesan',
'agentWorld.jobs.deadlineFuture': 'Batas waktu proposal harus di masa depan',
'nav.avatarMenu.account': 'Akun',
'nav.avatarMenu.billing': 'Tagihan',
'nav.avatarMenu.rewards': 'Hadiah',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profili',
'agentWorld.marketplace': 'Mercato',
'agentWorld.messaging': 'Messaggi',
'agentWorld.jobs.deadlineFuture': 'La scadenza della proposta deve essere nel futuro',
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Fatturazione',
'nav.avatarMenu.rewards': 'Premi',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': '프로필',
'agentWorld.marketplace': '마켓플레이스',
'agentWorld.messaging': '메시지',
'agentWorld.jobs.deadlineFuture': '제안 마감일은 미래여야 합니다',
'nav.avatarMenu.account': '계정',
'nav.avatarMenu.billing': '결제',
'nav.avatarMenu.rewards': '보상',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profile',
'agentWorld.marketplace': 'Rynek',
'agentWorld.messaging': 'Wiadomości',
'agentWorld.jobs.deadlineFuture': 'Termin złożenia oferty musi być w przyszłości',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Rozliczenia',
'nav.avatarMenu.rewards': 'Nagrody',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Perfis',
'agentWorld.marketplace': 'Mercado',
'agentWorld.messaging': 'Mensagens',
'agentWorld.jobs.deadlineFuture': 'O prazo da proposta deve ser no futuro',
'nav.avatarMenu.account': 'Conta',
'nav.avatarMenu.billing': 'Faturamento',
'nav.avatarMenu.rewards': 'Recompensas',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Профили',
'agentWorld.marketplace': 'Маркетплейс',
'agentWorld.messaging': 'Сообщения',
'agentWorld.jobs.deadlineFuture': 'Срок подачи предложения должен быть в будущем',
'nav.avatarMenu.account': 'Аккаунт',
'nav.avatarMenu.billing': 'Оплата',
'nav.avatarMenu.rewards': 'Награды',
+1
View File
@@ -52,6 +52,7 @@ const messages: TranslationMap = {
'agentWorld.profiles': '档案',
'agentWorld.marketplace': '市场',
'agentWorld.messaging': '消息',
'agentWorld.jobs.deadlineFuture': '提案截止日期必须是将来的日期',
'nav.avatarMenu.account': '账户',
'nav.avatarMenu.billing': '账单',
'nav.avatarMenu.rewards': '奖励',