fix(companies): align optional numeric fields with API validation
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
Make employee_count, annual_revenue, and founded_year truly optional in the create company form and payload. Add field validation docs and Cypress e2e coverage for dashboard and notifications. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const apiResponse = (data: Record<string, unknown>) => ({
|
||||
success: true,
|
||||
message: "ok",
|
||||
data,
|
||||
});
|
||||
|
||||
const visitDashboard = () => {
|
||||
cy.visit("/", {
|
||||
onBeforeLoad(win) {
|
||||
cy.stub(win, "matchMedia").callsFake((query: string) => ({
|
||||
matches: query === "(prefers-reduced-motion: reduce)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const stubDashboardRequests = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/dashboard/summary**", (req) => {
|
||||
expect(req.query.closing_window).to.be.undefined;
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
total_tenders: 120,
|
||||
active_tenders: 72,
|
||||
awarded_tenders: 18,
|
||||
expired_tenders: 20,
|
||||
cancelled_tenders: 10,
|
||||
closing_soon: 12,
|
||||
total_estimated_value: 2500000,
|
||||
value_currency: "EUR",
|
||||
generated_at: now,
|
||||
}),
|
||||
});
|
||||
}).as("dashboardSummary");
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/dashboard/trend**", (req) => {
|
||||
expect(req.query).to.include({ days: "14", metric: "created" });
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
metric: "created",
|
||||
days: 14,
|
||||
series: [
|
||||
{ date: "2026-06-07", count: 4 },
|
||||
{ date: "2026-06-08", count: 7 },
|
||||
{ date: "2026-06-09", count: 9 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}).as("dashboardTrend");
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/dashboard/countries**", (req) => {
|
||||
expect(req.query.limit).to.eq("6");
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
total: 120,
|
||||
items: [
|
||||
{ country_code: "DE", count: 60 },
|
||||
{ country_code: "FR", count: 30 },
|
||||
{ country_code: "NL", count: 10 },
|
||||
],
|
||||
other_count: 20,
|
||||
}),
|
||||
});
|
||||
}).as("dashboardCountries");
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/dashboard/notice-types**", {
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
total: 100,
|
||||
items: [
|
||||
{ type: "CONTRACT_NOTICE", count: 70 },
|
||||
{ type: "AWARD_NOTICE", count: 30 },
|
||||
],
|
||||
}),
|
||||
}).as("dashboardNoticeTypes");
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/dashboard/closing-soon**", (req) => {
|
||||
expect(req.query.limit).to.eq("5");
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
items: [
|
||||
{
|
||||
id: "closing-tender-1",
|
||||
title: "Railway signaling upgrade",
|
||||
country_code: "DE",
|
||||
buyer_name: "Federal Rail Agency",
|
||||
submission_deadline: now + 36 * 60 * 60,
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}).as("dashboardClosingSoon");
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/tenders**", (req) => {
|
||||
expect(req.query).to.include({
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
offset: "0",
|
||||
limit: "5",
|
||||
});
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
tenders: [
|
||||
{
|
||||
id: "recent-tender-1",
|
||||
title: "Hospital equipment framework",
|
||||
country_code: "FR",
|
||||
status: "active",
|
||||
created_at: now - 60 * 60,
|
||||
buyer_organization: { name: "Central Health Buyer" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}).as("recentTenders");
|
||||
|
||||
cy.intercept("GET", "**/api/proxy/dashboard/statistics**", (req) => {
|
||||
const days = Number(req.query.days);
|
||||
req.alias = days === 30 ? "statistics30Days" : "statistics14Days";
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: apiResponse({
|
||||
days,
|
||||
generated_at: now,
|
||||
daily: {
|
||||
scraped_ted: [
|
||||
{ date: "2026-06-08", count: days === 30 ? 12 : 5 },
|
||||
{ date: "2026-06-09", count: days === 30 ? 18 : 8 },
|
||||
],
|
||||
scraped_documents: [
|
||||
{ date: "2026-06-08", count: days === 30 ? 40 : 20 },
|
||||
{ date: "2026-06-09", count: days === 30 ? 60 : 30 },
|
||||
],
|
||||
translated_notices: [
|
||||
{ date: "2026-06-08", count: days === 30 ? 7 : 3 },
|
||||
{ date: "2026-06-09", count: days === 30 ? 11 : 6 },
|
||||
],
|
||||
},
|
||||
totals: {
|
||||
scraped_documents: days === 30 ? 9876 : 4321,
|
||||
translated_tenders: days === 30 ? 654 : 321,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("tender dashboard", () => {
|
||||
beforeEach(() => {
|
||||
cy.setAuthCookies();
|
||||
stubDashboardRequests();
|
||||
});
|
||||
|
||||
it("renders the new dashboard sections with API data and detail links", () => {
|
||||
visitDashboard();
|
||||
|
||||
cy.wait([
|
||||
"@dashboardSummary",
|
||||
"@dashboardTrend",
|
||||
"@dashboardCountries",
|
||||
"@dashboardNoticeTypes",
|
||||
"@dashboardClosingSoon",
|
||||
"@recentTenders",
|
||||
"@statistics14Days",
|
||||
]);
|
||||
|
||||
cy.get('h1[aria-label*="welcome to OppLens"]')
|
||||
.should("be.visible")
|
||||
.and("have.attr", "aria-label")
|
||||
.and("include", "welcome to OppLens");
|
||||
|
||||
cy.contains("Total Tenders").parent().should("contain.text", "120");
|
||||
cy.contains("Active Now").parent().should("contain.text", "72");
|
||||
cy.contains("Closing Soon").parent().should("contain.text", "12");
|
||||
cy.contains("Estimated Value").parent().should("contain.text", "€2.5M");
|
||||
|
||||
cy.contains("Tender Flow — Last 14 days").should("be.visible");
|
||||
cy.contains("By Country").should("be.visible");
|
||||
cy.contains(/^DE$/).should("be.visible");
|
||||
cy.contains("Contract Notice").should("be.visible");
|
||||
cy.contains("+70 · 70%").should("be.visible");
|
||||
|
||||
cy.contains("Railway signaling upgrade")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/tenders/closing-tender-1");
|
||||
cy.contains("Hospital equipment framework")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/tenders/recent-tender-1");
|
||||
|
||||
cy.contains("Scraping & Translation").should("be.visible");
|
||||
cy.contains("Total Scraped Documents").parent().should("contain.text", "4,321");
|
||||
cy.contains("Total Translated Tenders").parent().should("contain.text", "321");
|
||||
cy.contains("Scraped TED per day").should("be.visible");
|
||||
cy.contains("Scraped documents per day").should("be.visible");
|
||||
cy.contains("Translated notices per day").should("be.visible");
|
||||
});
|
||||
|
||||
it("reloads scraping and translation statistics for a selected range", () => {
|
||||
visitDashboard();
|
||||
cy.wait("@statistics14Days");
|
||||
|
||||
cy.contains("button", "14d").should("have.class", "bg-primary");
|
||||
cy.contains("button", "30d").click();
|
||||
cy.wait("@statistics30Days")
|
||||
.its("request.query.days")
|
||||
.should("eq", "30");
|
||||
|
||||
cy.contains("button", "30d").should("have.class", "bg-primary");
|
||||
cy.contains("Total Scraped Documents").parent().should("contain.text", "9,876");
|
||||
cy.contains("Total Translated Tenders").parent().should("contain.text", "654");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const emptyHistoryResponse = {
|
||||
success: true,
|
||||
message: "ok",
|
||||
data: [],
|
||||
meta: {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
has_more: false,
|
||||
},
|
||||
};
|
||||
|
||||
const stubNotificationReads = () => {
|
||||
cy.intercept("GET", "**/api/proxy/notifications/my**", {
|
||||
statusCode: 200,
|
||||
body: emptyHistoryResponse,
|
||||
});
|
||||
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, {
|
||||
statusCode: 200,
|
||||
body: emptyHistoryResponse,
|
||||
}).as("notificationHistory");
|
||||
};
|
||||
|
||||
const chooseMultiSelectOption = (label: string, option: string) => {
|
||||
cy.contains("label", new RegExp(`^${label}`, "i"))
|
||||
.parent()
|
||||
.find('div[tabindex="0"]')
|
||||
.click();
|
||||
cy.contains(
|
||||
"div[class*='cursor-pointer']",
|
||||
new RegExp(`^${option}$`),
|
||||
).click();
|
||||
};
|
||||
|
||||
describe("create notification", () => {
|
||||
beforeEach(() => {
|
||||
cy.setAuthCookies();
|
||||
stubNotificationReads();
|
||||
});
|
||||
|
||||
it("renders breadcrumbs and validates required fields and an invalid link", () => {
|
||||
cy.visit("/notification-history/create");
|
||||
|
||||
cy.getByCy("breadcrumb-link-dashboard").should("be.visible");
|
||||
cy.getByCy("breadcrumb-link-notifications").should(
|
||||
"have.attr",
|
||||
"href",
|
||||
"/notification-history",
|
||||
);
|
||||
cy.getByCy("breadcrumb-current-create-notification")
|
||||
.should("be.visible")
|
||||
.and("contain.text", "Create Notification");
|
||||
|
||||
cy.get('textarea[name="description"]').type(
|
||||
"Description entered so custom validation can run.",
|
||||
);
|
||||
cy.getByCy("admin-form-submit-button").click();
|
||||
cy.get("p")
|
||||
.filter(
|
||||
(_index, element) =>
|
||||
element.textContent?.trim() === "This field is required",
|
||||
)
|
||||
.should("have.length", 4);
|
||||
|
||||
cy.get('input[name="link"]').type("not-a-valid-url");
|
||||
cy.get('input[name="title"]').type("Validation test");
|
||||
cy.get('select[name="target"]').select("all_users", { force: true });
|
||||
cy.get('select[name="priority"]').select("medium", { force: true });
|
||||
cy.get('select[name="type"]').select("info", { force: true });
|
||||
cy.get('textarea[name="description"]')
|
||||
.clear()
|
||||
.type("A valid notification message.");
|
||||
cy.getByCy("admin-form-submit-button").click();
|
||||
|
||||
cy.contains("This field does not match the required pattern").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads role recipients and submits the expected notification payload", () => {
|
||||
cy.intercept("POST", "**/api/proxy/notifications", (req) => {
|
||||
expect(req.body).to.deep.equal({
|
||||
title: "Planned maintenance",
|
||||
target: "specific_role",
|
||||
recipient: ["admin"],
|
||||
channels: ["email", "push"],
|
||||
link: "https://example.com/maintenance",
|
||||
priority: "important",
|
||||
type: "warning",
|
||||
description: "The platform will be unavailable during maintenance.",
|
||||
schedule_at: 0,
|
||||
});
|
||||
req.reply({
|
||||
statusCode: 201,
|
||||
body: {
|
||||
success: true,
|
||||
message: "Notification created successfully",
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
}).as("createNotification");
|
||||
|
||||
cy.visit("/notification-history");
|
||||
cy.wait("@notificationHistory");
|
||||
cy.getByCy("list-header-create-button").click();
|
||||
cy.location("pathname").should("eq", "/notification-history/create");
|
||||
|
||||
cy.get('input[name="title"]').type("Planned maintenance");
|
||||
cy.get('select[name="target"]').select("specific_role", { force: true });
|
||||
chooseMultiSelectOption("recipient", "Admin");
|
||||
chooseMultiSelectOption("channels", "Email");
|
||||
chooseMultiSelectOption("channels", "Push");
|
||||
cy.get('input[name="link"]').type("https://example.com/maintenance");
|
||||
cy.get('select[name="priority"]').select("important", { force: true });
|
||||
cy.get('select[name="type"]').select("warning", { force: true });
|
||||
cy.get('textarea[name="description"]').type(
|
||||
"The platform will be unavailable during maintenance.",
|
||||
);
|
||||
|
||||
cy.getByCy("admin-form-submit-button").click();
|
||||
cy.wait("@createNotification");
|
||||
|
||||
cy.location("pathname").should("eq", "/notification-history");
|
||||
cy.contains("Notification created successfully").should("be.visible");
|
||||
});
|
||||
|
||||
it("loads specific users from the API and clears recipients when target changes", () => {
|
||||
cy.intercept("GET", "**/api/proxy/users**", (req) => {
|
||||
expect(req.query.offset).to.eq("0");
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
success: true,
|
||||
message: "ok",
|
||||
data: {
|
||||
users: [
|
||||
{
|
||||
id: "admin-recipient-1",
|
||||
full_name: "Notification Admin",
|
||||
username: "notification-admin",
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { offset: 0, limit: 10, total: 1 },
|
||||
},
|
||||
});
|
||||
}).as("notificationRecipients");
|
||||
|
||||
cy.visit("/notification-history/create");
|
||||
cy.get('select[name="target"]').select("specific_users", { force: true });
|
||||
cy.wait("@notificationRecipients");
|
||||
|
||||
chooseMultiSelectOption("recipient", "Notification Admin");
|
||||
cy.contains("span", "Notification Admin").should("be.visible");
|
||||
|
||||
cy.get('select[name="target"]').select("all_users", { force: true });
|
||||
cy.contains("label", /^recipient/i)
|
||||
.parent()
|
||||
.find('[tabindex="-1"]')
|
||||
.should("exist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
type NotificationRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
event_type: string;
|
||||
created_at: number;
|
||||
message: string;
|
||||
priority: string;
|
||||
type: string;
|
||||
seen: boolean;
|
||||
status: string;
|
||||
recipient?: { full_name: string };
|
||||
};
|
||||
|
||||
const makeNotification = (
|
||||
overrides: Partial<NotificationRecord> = {},
|
||||
): NotificationRecord => ({
|
||||
id: "notification-1",
|
||||
title: "Tender deadline reminder",
|
||||
event_type: "push",
|
||||
created_at: 1_717_934_400,
|
||||
message: "A watched tender is closing soon and needs your attention.",
|
||||
priority: "important",
|
||||
type: "warning",
|
||||
seen: false,
|
||||
status: "sent",
|
||||
recipient: { full_name: "E2E Admin One" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const historyResponse = (
|
||||
notifications: NotificationRecord[],
|
||||
options: { offset?: number; total?: number; pages?: number } = {},
|
||||
) => ({
|
||||
success: true,
|
||||
message: "ok",
|
||||
data: notifications,
|
||||
meta: {
|
||||
offset: options.offset ?? 0,
|
||||
limit: 10,
|
||||
total: options.total ?? notifications.length,
|
||||
page: Math.floor((options.offset ?? 0) / 10) + 1,
|
||||
pages: options.pages ?? 1,
|
||||
has_more: false,
|
||||
},
|
||||
});
|
||||
|
||||
const stubHeaderNotifications = () => {
|
||||
cy.intercept("GET", "**/api/proxy/notifications/my**", {
|
||||
statusCode: 200,
|
||||
body: historyResponse([]),
|
||||
}).as("myNotifications");
|
||||
};
|
||||
|
||||
describe("notification history", () => {
|
||||
beforeEach(() => {
|
||||
cy.setAuthCookies();
|
||||
stubHeaderNotifications();
|
||||
});
|
||||
|
||||
it("renders breadcrumbs, actions, columns, API rows, and details navigation", () => {
|
||||
const notification = makeNotification();
|
||||
|
||||
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
|
||||
expect(req.query).to.include({
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
offset: "0",
|
||||
limit: "10",
|
||||
});
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: historyResponse([notification]),
|
||||
});
|
||||
}).as("notificationHistory");
|
||||
|
||||
cy.visit("/notification-history");
|
||||
cy.wait("@notificationHistory");
|
||||
|
||||
cy.getByCy("breadcrumb-link-dashboard").should("be.visible");
|
||||
cy.getByCy("breadcrumb-current-notification-history")
|
||||
.should("be.visible")
|
||||
.and("contain.text", "Notification history");
|
||||
cy.getByCy("list-header-create-button").should(
|
||||
"contain.text",
|
||||
"Create notification",
|
||||
);
|
||||
|
||||
[
|
||||
"row",
|
||||
"title",
|
||||
"channel",
|
||||
"created at",
|
||||
"receiver",
|
||||
"message",
|
||||
"priority",
|
||||
"type",
|
||||
"seen",
|
||||
"status",
|
||||
"actions",
|
||||
].forEach((column) => {
|
||||
cy.contains("th", new RegExp(`^${column}$`, "i")).should("be.visible");
|
||||
});
|
||||
|
||||
cy.contains("tr", notification.title)
|
||||
.should("contain.text", notification.event_type)
|
||||
.and("contain.text", notification.recipient?.full_name)
|
||||
.and("contain.text", notification.priority)
|
||||
.and("contain.text", notification.type)
|
||||
.and("contain.text", notification.status)
|
||||
.within(() => {
|
||||
cy.get('button[data-tooltip-id="details"]').click();
|
||||
});
|
||||
|
||||
cy.location("pathname").should(
|
||||
"eq",
|
||||
`/notification-history/${notification.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by seen state and search, then resets to the default list", () => {
|
||||
const defaultNotification = makeNotification({
|
||||
id: "notification-default",
|
||||
title: "Default notification",
|
||||
});
|
||||
const filteredNotification = makeNotification({
|
||||
id: "notification-filtered",
|
||||
title: "Filtered urgent notice",
|
||||
seen: true,
|
||||
});
|
||||
|
||||
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
|
||||
const isFiltered =
|
||||
req.query.seen === "true" && req.query.search === "urgent";
|
||||
req.alias = isFiltered ? "filteredNotifications" : "defaultNotifications";
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: historyResponse([
|
||||
isFiltered ? filteredNotification : defaultNotification,
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
cy.visit("/notification-history");
|
||||
cy.wait("@defaultNotifications");
|
||||
cy.contains(defaultNotification.title).should("be.visible");
|
||||
|
||||
cy.getByCy("notification-history-filter-panel-toggle").click();
|
||||
cy.get('select[name="seen"]').select("true", { force: true });
|
||||
cy.getByCy("notification-history-filter-search-input").type("urgent");
|
||||
cy.getByCy("notification-history-filter-submit-button").click();
|
||||
|
||||
cy.wait("@filteredNotifications");
|
||||
cy.location("search")
|
||||
.should("include", "seen=true")
|
||||
.and("include", "search=urgent");
|
||||
cy.contains(filteredNotification.title).should("be.visible");
|
||||
cy.contains(defaultNotification.title).should("not.exist");
|
||||
cy.contains("2 active").should("be.visible");
|
||||
|
||||
cy.getByCy("notification-history-filter-reset-button").click();
|
||||
cy.wait("@defaultNotifications");
|
||||
cy.location("search").should("eq", "");
|
||||
cy.contains(defaultNotification.title).should("be.visible");
|
||||
});
|
||||
|
||||
it("shows loading and empty states, and opens the create page", () => {
|
||||
cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
|
||||
req.reply({
|
||||
delay: 900,
|
||||
statusCode: 200,
|
||||
body: historyResponse([]),
|
||||
});
|
||||
}).as("emptyNotificationHistory");
|
||||
|
||||
cy.visit("/notification-history");
|
||||
cy.getByCy("table-skeleton").should("be.visible");
|
||||
cy.wait("@emptyNotificationHistory");
|
||||
cy.getByCy("table-skeleton").should("not.exist");
|
||||
cy.getByCy("empty-list-message").should(
|
||||
"contain.text",
|
||||
"No notifications were found",
|
||||
);
|
||||
|
||||
cy.getByCy("list-header-create-button").click();
|
||||
cy.location("pathname").should("eq", "/notification-history/create");
|
||||
cy.getByCy("breadcrumb-current-create-notification").should("be.visible");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user