diff --git a/requirements.txt b/requirements.txt index 843fd0065f..1f6006360e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ canonicalwebteam.discourse==7.2.0 canonicalwebteam.blog==6.8.4 canonicalwebteam.search==2.1.2 canonicalwebteam.image-template==1.9.0 -canonicalwebteam.store-api==7.8.2 +canonicalwebteam.store-api==7.8.3 canonicalwebteam.launchpad==0.9.0 django-openid-auth==0.17 Flask-OpenID==1.3.1 diff --git a/static/js/publisher/hooks/__tests__/useSerialLogs.test.tsx b/static/js/publisher/hooks/__tests__/useSerialLogs.test.tsx index 4a0f58d5d7..527615957b 100644 --- a/static/js/publisher/hooks/__tests__/useSerialLogs.test.tsx +++ b/static/js/publisher/hooks/__tests__/useSerialLogs.test.tsx @@ -169,14 +169,14 @@ describe("useSerialLogs", () => { }); }); - test("returns serial logs data with nextPage param", async () => { + test("returns serial logs data with page param", async () => { server.use( http.get( "/api/store/test-brand-id/models/test-model/serial-log", ({ request }) => { const url = new URL(request.url); - expect(url.searchParams.get("next-page")).toBe("nextpagecursor"); + expect(url.searchParams.get("page")).toBe("pagecursor"); return HttpResponse.json({ data: serialLogsResponse, success: true, @@ -188,7 +188,7 @@ describe("useSerialLogs", () => { const { result } = renderHook( () => useSerialLogs("test-brand-id", "test-model", { - nextPage: "nextpagecursor", + page: "pagecursor", }), { wrapper: createWrapper(), @@ -205,7 +205,7 @@ describe("useSerialLogs", () => { }); }); - test("returns serial logs data with startTime, endTime, pageSize and nextPage params", async () => { + test("returns serial logs data with startTime, endTime, pageSize and page params", async () => { server.use( http.get( "/api/store/test-brand-id/models/test-model/serial-log", @@ -218,7 +218,7 @@ describe("useSerialLogs", () => { "2026-03-28T04:00:23.875000", ); expect(url.searchParams.get("page-size")).toBe("10"); - expect(url.searchParams.get("next-page")).toBe("nextpagecursor"); + expect(url.searchParams.get("page")).toBe("pagecursor"); return HttpResponse.json({ data: serialLogsResponse, success: true, @@ -235,7 +235,7 @@ describe("useSerialLogs", () => { endTime: "2026-03-28T04:00:23.875000", }, pageSize: 10, - nextPage: "nextpagecursor", + page: "pagecursor", }), { wrapper: createWrapper(), diff --git a/static/js/publisher/hooks/useSerialLogs.ts b/static/js/publisher/hooks/useSerialLogs.ts index de58835513..b4926de390 100644 --- a/static/js/publisher/hooks/useSerialLogs.ts +++ b/static/js/publisher/hooks/useSerialLogs.ts @@ -5,8 +5,8 @@ const useSerialLogs = ( brandId: string | undefined, modelId: string | undefined, urlSearchParams?: { + page?: string | null; pageSize?: number; - nextPage?: string; interval?: { startTime: string; endTime: string; @@ -19,7 +19,7 @@ const useSerialLogs = ( ); if (urlSearchParams) { - const { interval, pageSize, nextPage } = urlSearchParams; + const { interval, pageSize, page } = urlSearchParams; if (interval) { url.searchParams.set("start-time", interval.startTime); @@ -30,15 +30,22 @@ const useSerialLogs = ( url.searchParams.set("page-size", pageSize.toString()); } - if (nextPage) { - url.searchParams.set("next-page", nextPage); + if (page) { + url.searchParams.set("page", page); } } return useQuery, Error>({ - queryKey: ["serials", brandId, modelId, urlSearchParams], + queryKey: [ + "serials", + brandId, + modelId, + urlSearchParams?.page, + urlSearchParams?.pageSize, + urlSearchParams?.interval, + ], queryFn: async () => { - const response = await fetch(url.toString()); + const response = await fetch(url); const responseData = await response.json(); return responseData; diff --git a/static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx b/static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx index 713e35f11e..91d0fe68d7 100644 --- a/static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx +++ b/static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx @@ -1,13 +1,14 @@ import { Link, useParams } from "react-router-dom"; import { useAtomValue } from "jotai"; -import { useRemodels } from "../../hooks"; +import { useRemodels, useSerialLogs } from "../../hooks"; import { brandIdState } from "../../state/brandStoreState"; function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element { const { id, modelId } = useParams(); const brandId = useAtomValue(brandIdState); const { data: remodelsData } = useRemodels(brandId); + const { data: serialLogsData } = useSerialLogs(brandId, modelId); return ( ); diff --git a/static/js/publisher/layouts/ModelDetailsPageLayout/__tests__/ModelNav.test.tsx b/static/js/publisher/layouts/ModelDetailsPageLayout/__tests__/ModelNav.test.tsx index e38178ef2a..be18c6d79f 100644 --- a/static/js/publisher/layouts/ModelDetailsPageLayout/__tests__/ModelNav.test.tsx +++ b/static/js/publisher/layouts/ModelDetailsPageLayout/__tests__/ModelNav.test.tsx @@ -5,13 +5,18 @@ import { vi } from "vitest"; import "@testing-library/jest-dom"; import ModelNav from "../ModelNav"; -import { useRemodels } from "../../../hooks"; +import { useRemodels, useSerialLogs } from "../../../hooks"; import type { UseQueryResult } from "react-query"; -import type { ApiResponse, RemodelResponse } from "../../../types/shared"; +import type { + ApiResponse, + RemodelResponse, + SerialLogResponse, +} from "../../../types/shared"; vi.mock("../../../hooks", () => ({ useRemodels: vi.fn(), + useSerialLogs: vi.fn(), })); vi.mock("../../../state/brandStoreState", () => ({ @@ -47,6 +52,17 @@ describe("ModelNav", () => { }, } as unknown as UseQueryResult, Error>); + const mockUseSerialLogs = vi.mocked(useSerialLogs); + mockUseSerialLogs.mockReturnValue({ + data: { + success: true, + data: { + items: [], + "next-cursor": null, + }, + }, + } as unknown as UseQueryResult, Error>); + renderComponent("policies"); const currentLink = screen.getByRole("tab", { name: "Policies" }); expect(currentLink.getAttribute("aria-selected")).toBe("true"); @@ -64,6 +80,17 @@ describe("ModelNav", () => { }, } as unknown as UseQueryResult, Error>); + const mockUseSerialLogs = vi.mocked(useSerialLogs); + mockUseSerialLogs.mockReturnValue({ + data: { + success: true, + data: { + items: [], + "next-cursor": null, + }, + }, + } as unknown as UseQueryResult, Error>); + renderComponent("policies"); const currentLink = screen.getByRole("tab", { name: "Overview" }); expect(currentLink.getAttribute("aria-selected")).toBe("false"); @@ -81,6 +108,17 @@ describe("ModelNav", () => { }, } as unknown as UseQueryResult, Error>); + const mockUseSerialLogs = vi.mocked(useSerialLogs); + mockUseSerialLogs.mockReturnValue({ + data: { + success: true, + data: { + items: [], + "next-cursor": null, + }, + }, + } as unknown as UseQueryResult, Error>); + renderComponent("overview"); expect(screen.getByRole("tab", { name: "Remodel" })).toBeInTheDocument(); }); @@ -98,6 +136,17 @@ describe("ModelNav", () => { }, } as unknown as UseQueryResult, Error>); + const mockUseSerialLogs = vi.mocked(useSerialLogs); + mockUseSerialLogs.mockReturnValue({ + data: { + success: false, + data: { + items: [], + "next-cursor": null, + }, + }, + } as unknown as UseQueryResult, Error>); + renderComponent("overview"); expect( screen.queryByRole("tab", { name: "Remodel" }), @@ -110,6 +159,11 @@ describe("ModelNav", () => { data: undefined, } as unknown as UseQueryResult, Error>); + const mockUseSerialLogs = vi.mocked(useSerialLogs); + mockUseSerialLogs.mockReturnValue({ + data: undefined, + } as unknown as UseQueryResult, Error>); + renderComponent("overview"); expect( screen.queryByRole("tab", { name: "Remodel" }), diff --git a/static/js/publisher/pages/Remodel/Remodel.tsx b/static/js/publisher/pages/Remodel/Remodel.tsx index 6760058ebb..096c4c837f 100644 --- a/static/js/publisher/pages/Remodel/Remodel.tsx +++ b/static/js/publisher/pages/Remodel/Remodel.tsx @@ -70,7 +70,10 @@ function Remodel(): React.JSX.Element { // because otherwise the cursor history gets out of sync setCurrentCursor(null); cursorHistory.current = []; - setSearchParams({ "page-size": newPageSize.toString() }); + setSearchParams((params) => { + params.set("page-size", newPageSize.toString()); + return params; + }); }; brandStore diff --git a/static/js/publisher/pages/Remodel/__tests__/RemodelTable.test.tsx b/static/js/publisher/pages/Remodel/__tests__/RemodelTable.test.tsx index 07e4bbba8f..8017312a80 100644 --- a/static/js/publisher/pages/Remodel/__tests__/RemodelTable.test.tsx +++ b/static/js/publisher/pages/Remodel/__tests__/RemodelTable.test.tsx @@ -18,7 +18,14 @@ const renderComponent = () => { return render( - + , ); diff --git a/static/js/publisher/pages/SerialLog/SerialLog.tsx b/static/js/publisher/pages/SerialLog/SerialLog.tsx index ac53a6a8e0..e1b7efd5a9 100644 --- a/static/js/publisher/pages/SerialLog/SerialLog.tsx +++ b/static/js/publisher/pages/SerialLog/SerialLog.tsx @@ -1,6 +1,6 @@ -import { useEffect } from "react"; +import { useEffect, useState, useRef } from "react"; import { useAtomValue, useSetAtom } from "jotai"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { Notification, Icon } from "@canonical/react-components"; import { useSerialLogs } from "../../hooks"; @@ -15,7 +15,23 @@ import type { SerialLogResponse, ApiResponse } from "../../types/shared"; function SerialLog(): React.JSX.Element { const { id, modelId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); const brandId = useAtomValue(brandIdState); + const [currentCursor, setCurrentCursor] = useState(null); + const [nextCursor, setNextCursor] = useState(null); + const cursorHistory = useRef>([]); + + const pageSizeParam = searchParams.get("page-size"); + const parsedPageSize = pageSizeParam ? parseInt(pageSizeParam) : NaN; + const startTime = searchParams.get("start-time"); + const endTime = searchParams.get("end-time"); + const pageSize = Number.isInteger(parsedPageSize) ? parsedPageSize : 25; + const params = { + pageSize: pageSize, + page: currentCursor, + ...(startTime && endTime && { interval: { startTime, endTime } }), + }; + const { isLoading, isError, @@ -24,17 +40,44 @@ function SerialLog(): React.JSX.Element { }: UseQueryResult, Error> = useSerialLogs( brandId, modelId, + params, ); const setSerialLogs = useSetAtom(serialLogsListState); const brandStore = useAtomValue(brandStoreState(id)); + const handlePageForward = () => { + cursorHistory.current.push(currentCursor); + setCurrentCursor(nextCursor); + }; + + const handlePageBack = () => { + const lastCursor = cursorHistory.current.pop(); + setCurrentCursor(lastCursor || null); + }; + + const handlePageSizeChange = (newPageSize: number) => { + // Need to reset current page when changing page size + // because otherwise the cursor history gets out of sync + setCurrentCursor(null); + cursorHistory.current = []; + setSearchParams((params) => { + params.set("page-size", newPageSize.toString()); + return params; + }); + }; + brandStore ? setPageTitle(`Serial logs in ${brandStore.name}`) : setPageTitle("Serial logs"); useEffect(() => { - if (!isLoading && !isError && data) { + if (isLoading || isError) { + return; + } + + if (data) { setSerialLogs(data.data?.items || []); + setNextCursor(data.data?.["next-cursor"] || null); } }, [isLoading, isError, data]); @@ -57,7 +100,16 @@ function SerialLog(): React.JSX.Element { ) : (
- +
)} diff --git a/static/js/publisher/pages/SerialLog/SerialLogTable.tsx b/static/js/publisher/pages/SerialLog/SerialLogTable.tsx index 8e6f3748cf..af00187d31 100644 --- a/static/js/publisher/pages/SerialLog/SerialLogTable.tsx +++ b/static/js/publisher/pages/SerialLog/SerialLogTable.tsx @@ -1,6 +1,9 @@ import { useAtomValue } from "jotai"; import { useParams } from "react-router-dom"; -import { MainTable, TablePagination } from "@canonical/react-components"; +import { + MainTable, + TablePaginationControls, +} from "@canonical/react-components"; import { format } from "date-fns"; import { brandStoreState } from "../../state/brandStoreState"; @@ -8,18 +11,34 @@ import { serialLogsListState } from "../../state/serialLogsState"; import type { SerialLog } from "../../types/shared"; -function SerialLogTable(): React.JSX.Element { +type Props = { + handlePageForward: () => void; + handlePageBack: () => void; + handlePageSizeChange: (arg: number) => void; + forwardDisabled: boolean; + backDisabled: boolean; + pageSize: number; +}; + +function SerialLogTable({ + handlePageForward, + handlePageBack, + handlePageSizeChange, + forwardDisabled, + backDisabled, + pageSize, +}: Props): React.JSX.Element { const { id } = useParams(); const serialLogs = useAtomValue(serialLogsListState); const brandStore = useAtomValue(brandStoreState(id)); const headers = [ - { content: "Brand" }, - { content: "Model" }, - { content: "Serial" }, + { content: "Brand", className: "u-truncate" }, + { content: "Model", className: "u-truncate" }, + { content: "Serial", className: "u-truncate" }, { content: "Created date", - className: "u-align--right", + className: "u-align--right u-truncate", }, ]; @@ -38,17 +57,37 @@ function SerialLogTable(): React.JSX.Element { }); return ( - + <> + 0, to remove the "of more than x rows" in the visible + // count section. See: + // https://canonical.github.io/react-components/?path=/story/components-tablepagination--controls-with-partially-known-entries + currentPage={1} + itemName="serial log" + nextButtonProps={{ + disabled: forwardDisabled, + }} + onNextPage={handlePageForward} + onPageSizeChange={handlePageSizeChange} + onPreviousPage={handlePageBack} + pageLimits={[10, 25, 50, 100, 500]} + pageSize={pageSize} + previousButtonProps={{ + disabled: backDisabled, + }} + showPageInput={false} + visibleCount={rows.length} + className="table-pagination-controls" /> - + ); } diff --git a/static/js/publisher/pages/SerialLog/__tests__/SerialLogTable.test.tsx b/static/js/publisher/pages/SerialLog/__tests__/SerialLogTable.test.tsx index d7d979ba35..671f5449e1 100644 --- a/static/js/publisher/pages/SerialLog/__tests__/SerialLogTable.test.tsx +++ b/static/js/publisher/pages/SerialLog/__tests__/SerialLogTable.test.tsx @@ -18,7 +18,14 @@ const renderComponent = () => { return render( - + , ); diff --git a/tests/endpoints/tests_models.py b/tests/endpoints/tests_models.py index 0366c0be3e..58c1f55018 100644 --- a/tests/endpoints/tests_models.py +++ b/tests/endpoints/tests_models.py @@ -890,45 +890,56 @@ class TestGetSerialLog(TestModelServiceEndpoints): "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_success(self, mock_get_serial_log): - mock_serial_log = [ - { - "brand-id": "test-brand-id", - "created-at": "2026-03-23T04:00:23.875000", - "model-name": "test-model", - "serial": "test-serial", - } - ] - mock_get_serial_log.return_value = mock_serial_log - response = self.client.get("/api/store/1/models/test-model/serial-log") + def test_get_serial_log_success(self, mock_get_serial_logs): + mock_serial_logs = { + "items": [ + { + "brand-id": "test-brand-id", + "created-at": "2026-03-27T14:34:23.666Z", + "model-name": "test-model", + "serial": "test-serial", + } + ] + } + mock_get_serial_logs.return_value = mock_serial_logs + + response = self.client.get( + "/api/store/test-store-id/models/test-model/serial-log" + ) data = response.json self.assertEqual(response.status_code, 200) self.assertTrue(data["success"]) - self.assertEqual(data["data"], mock_serial_log) + self.assertEqual(data["data"], mock_serial_logs) @patch( "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_empty(self, mock_get_serial_log): - mock_get_serial_log.return_value = [] - response = self.client.get("/api/store/1/models/test-model/serial-log") + def test_get_serial_log_empty(self, mock_get_serial_logs): + mock_get_serial_logs.return_value = {"items": []} + + response = self.client.get( + "/api/store/test-store-id/models/test-model/serial-log" + ) data = response.json self.assertEqual(response.status_code, 200) self.assertTrue(data["success"]) - self.assertEqual(data["data"], []) + self.assertEqual(data["data"]["items"], []) @patch( "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_unauthorized(self, mock_get_serial_log): - mock_get_serial_log.side_effect = StoreApiResponseErrorList( + def test_get_serial_log_unauthorized(self, mock_get_serial_logs): + mock_get_serial_logs.side_effect = StoreApiResponseErrorList( "unauthorized", 401, [{"message": "unauthorized"}] ) - response = self.client.get("/api/store/1/models/test-model/serial-log") + + response = self.client.get( + "/api/store/test-store-id/models/test-model/serial-log" + ) data = response.json self.assertEqual(response.status_code, 500) @@ -939,10 +950,11 @@ def test_get_serial_log_unauthorized(self, mock_get_serial_log): "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_store_not_found(self, mock_get_serial_log): - mock_get_serial_log.side_effect = StoreApiResponseErrorList( + def test_get_serial_log_store_not_found(self, mock_get_serial_logs): + mock_get_serial_logs.side_effect = StoreApiResponseErrorList( "Store not found", 404, [{"message": "Store not found"}] ) + response = self.client.get( "/api/store/999/models/test-model/serial-log" ) @@ -956,13 +968,16 @@ def test_get_serial_log_store_not_found(self, mock_get_serial_log): "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_general_error(self, mock_get_serial_log): - mock_get_serial_log.side_effect = StoreApiResponseErrorList( + def test_get_serial_log_general_error(self, mock_get_serial_logs): + mock_get_serial_logs.side_effect = StoreApiResponseErrorList( "Internal server error", 500, [{"message": "Internal server error"}], ) - response = self.client.get("/api/store/1/models/test-model/serial-log") + + response = self.client.get( + "/api/store/test-store-id/models/test-model/serial-log" + ) data = response.json self.assertEqual(response.status_code, 500) @@ -973,137 +988,140 @@ def test_get_serial_log_general_error(self, mock_get_serial_log): "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_with_time_range(self, mock_get_serial_log): - mock_serial_log = [] - mock_get_serial_log.return_value = mock_serial_log + def test_get_serial_log_with_page_parameter(self, mock_get_serial_logs): + mock_get_serial_logs.return_value = {"items": []} + response = self.client.get( - "/api/store/1/models/test-model/serial-log" - "?start-time=2026-06-01T00:00:00Z" - "&end-time=2026-06-30T23:59:59Z" + "/api/store/test-store-id/models/test-model/serial-log?" + "page=page-cursor" ) data = response.json self.assertEqual(response.status_code, 200) self.assertTrue(data["success"]) - self.assertEqual(data["data"], []) - - mock_get_serial_log.assert_called_once() - # Arguments are passed positionally: - # (session, store_id, model_name, start_time, end_time, page_size) - self.assertEqual( - mock_get_serial_log.call_args.args[3], - "2026-06-01T00:00:00Z", - ) - self.assertEqual( - mock_get_serial_log.call_args.args[4], - "2026-06-30T23:59:59Z", + mock_get_serial_logs.assert_called_once_with( + mock_get_serial_logs.call_args[0][0], + "test-store-id", + "test-model", + { + "cursor": "page-cursor", + "start_time": None, + "end_time": None, + "page_size": None, + }, ) @patch( "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_with_page_size(self, mock_get_serial_log): - mock_serial_log = [ - { - "brand-id": "test-brand-id", - "created-at": "2026-03-23T04:00:23.875000", - "model-name": "test-model", - "serial": "test-serial", - } - ] - mock_get_serial_log.return_value = mock_serial_log + def test_get_serial_log_with_start_and_end_time_parameter( + self, mock_get_serial_logs + ): + mock_get_serial_logs.return_value = {"items": []} + response = self.client.get( - "/api/store/1/models/test-model/serial-log?page-size=50" + "/api/store/test-store-id/models/test-model/serial-log?" + "start-time=2026-04-01T23:59:59Z&" + "end-time=2026-04-30T23:59:59Z" ) data = response.json self.assertEqual(response.status_code, 200) self.assertTrue(data["success"]) - self.assertEqual(data["data"], mock_serial_log) + mock_get_serial_logs.assert_called_once_with( + mock_get_serial_logs.call_args[0][0], + "test-store-id", + "test-model", + { + "cursor": None, + "start_time": "2026-04-01T23:59:59Z", + "end_time": "2026-04-30T23:59:59Z", + "page_size": None, + }, + ) - mock_get_serial_log.assert_called_once() - # Arguments are passed positionally: - # session, store_id, model_name, start_time, - # end_time, page_size, cursor - self.assertEqual( - mock_get_serial_log.call_args.args[5], - "50", + @patch( + "canonicalwebteam.store_api.publishergw" + ".PublisherGW.get_store_model_serial_logs" + ) + def test_get_serial_log_with_page_size_parameter( + self, mock_get_serial_logs + ): + mock_get_serial_logs.return_value = {"items": []} + + response = self.client.get( + "/api/store/test-store-id/models/test-model/serial-log?" + "page-size=25" + ) + data = response.json + + self.assertEqual(response.status_code, 200) + self.assertTrue(data["success"]) + mock_get_serial_logs.assert_called_once_with( + mock_get_serial_logs.call_args[0][0], + "test-store-id", + "test-model", + { + "cursor": None, + "start_time": None, + "end_time": None, + "page_size": "25", + }, ) @patch( "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_with_next_page(self, mock_get_serial_log): - mock_serial_log = [ - { - "brand-id": "test-brand-id", - "created-at": "2026-03-23T04:00:23.875000", - "model-name": "test-model", - "serial": "test-serial", - } - ] - mock_get_serial_log.return_value = mock_serial_log + def test_get_serial_log_with_multiple_parameters( + self, mock_get_serial_logs + ): + mock_get_serial_logs.return_value = {"items": []} + response = self.client.get( - "/api/store/1/models/test-model/serial-log?next-page=nextpage" + "/api/store/test-store-id/models/test-model/serial-log" + "?page=page-cursor&start-time=2026-04-01T00:00:00Z&" + "end-time=2026-04-30T23:59:59Z&page-size=25" ) data = response.json self.assertEqual(response.status_code, 200) self.assertTrue(data["success"]) - self.assertEqual(data["data"], mock_serial_log) - - mock_get_serial_log.assert_called_once() - # Arguments are passed positionally: - # session, store_id, model_name, start_time, - # end_time, page_size, cursor - self.assertEqual( - mock_get_serial_log.call_args.args[6], - "nextpage", + mock_get_serial_logs.assert_called_once_with( + mock_get_serial_logs.call_args[0][0], + "test-store-id", + "test-model", + { + "cursor": "page-cursor", + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-04-30T23:59:59Z", + "page_size": "25", + }, ) @patch( "canonicalwebteam.store_api.publishergw.PublisherGW" + ".get_store_model_serial_logs" ) - def test_get_serial_log_with_all_parameters(self, mock_get_serial_log): - mock_serial_log = [ - { - "brand-id": "test-brand-id", - "created-at": "2026-03-23T04:00:23.875000", - "model-name": "test-model", - "serial": "test-serial", - } - ] - mock_get_serial_log.return_value = mock_serial_log + def test_get_serial_log_with_no_parameters(self, mock_get_serial_logs): + mock_get_serial_logs.return_value = {"items": []} + response = self.client.get( - "/api/store/1/models/test-model/serial-log" - "?start-time=2026-01-01T00:00:00Z" - "&end-time=2026-12-31T23:59:59Z" - "&page-size=25" - "&next-page=nextpage" + "/api/store/test-store-id/models/test-model/serial-log" ) data = response.json self.assertEqual(response.status_code, 200) self.assertTrue(data["success"]) - self.assertEqual(data["data"], mock_serial_log) - - mock_get_serial_log.assert_called_once() - # Arguments are passed positionally: - # session, store_id, model_name, start_time, - # end_time, page_size, cursor - self.assertEqual( - mock_get_serial_log.call_args.args[3], - "2026-01-01T00:00:00Z", - ) - self.assertEqual( - mock_get_serial_log.call_args.args[4], - "2026-12-31T23:59:59Z", - ) - self.assertEqual( - mock_get_serial_log.call_args.args[5], - "25", + mock_get_serial_logs.assert_called_once_with( + mock_get_serial_logs.call_args[0][0], + "test-store-id", + "test-model", + { + "cursor": None, + "start_time": None, + "end_time": None, + "page_size": None, + }, ) - self.assertEqual(mock_get_serial_log.call_args.args[6], "nextpage") diff --git a/webapp/endpoints/models.py b/webapp/endpoints/models.py index 3c7e1fdc8a..dd1164b364 100644 --- a/webapp/endpoints/models.py +++ b/webapp/endpoints/models.py @@ -473,21 +473,19 @@ def delete_remodel_allowlist(store_id: str): @login_required @exchange_required def get_serial_log(store_id: str, model_name: str): - start_time = flask.request.args.get("start-time") - end_time = flask.request.args.get("end-time") - page_size = flask.request.args.get("page-size") - cursor = flask.request.args.get("next-page") + params = { + # rename `cursor` to `page` on our side for clarity + "cursor": flask.request.args.get("page"), + "start_time": flask.request.args.get("start-time"), + "end_time": flask.request.args.get("end-time"), + "page_size": flask.request.args.get("page-size"), + } + res = {} try: logs = publisher_gateway.get_store_model_serial_logs( - flask.session, - store_id, - model_name, - start_time, - end_time, - page_size, - cursor, + flask.session, store_id, model_name, params ) res["data"] = logs res["success"] = True