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
3 changes: 3 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"app/styles/critical-product.tailwind.cjs",
// Pulled in by Sass (@import in app.scss and critical.scss) and by the swiper/css vite alias; knip does not follow Sass.
"app/styles/vendor/swiper.css",
// Shared mock backend: a library of factories, scenarios and a browser worker that tests and
// Storybook/Playwright use as they need; not every export has a caller yet.
"tests/mocks/**",
],
// Generated by the TanStack router plugin; gitignored and absent on a clean checkout.
"ignoreUnresolved": ["^\\./routeTree\\.gen$"],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"globals": "^17.13.0",
"jsdom": "^30.0.1",
"knip": "^6.43.0",
"msw": "^3.0.3",
"postcss": "8.5.26",
"prettier": "^3.9.10",
"rollup-plugin-visualizer": "^7.1.1",
Expand Down
247 changes: 242 additions & 5 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

72 changes: 38 additions & 34 deletions tests/components/home/ProductsSlider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { ProductsSlider } from '../../../app/components/home/ProductsSlider';

const listMock = vi.fn();
import { STORE_API } from '../../mocks/handlers';
import { createProduct } from '../../mocks/factories';
import { installMockServer, server, useScenario } from '../../mocks/server';

installMockServer();

// Every `GET /products` the block makes, so a test can assert what it asked the API for.
let requests: URL[] = [];
const respondWith = (resolver: Parameters<typeof http.get>[1]) =>
server.use(
http.get(`${STORE_API}/products`, (info) => {
requests.push(new URL(info.request.url));
return resolver(info);
})
);

// Only the module under test, not the whole barrel and everything it re-exports.
vi.mock('../../../app/components/common', async () => ({
Expand All @@ -18,18 +32,6 @@ vi.mock('@salla.sa/twilight-theme-engine/i18n', () => ({
useTranslation: () => ({ t: (_k: string, fb?: string) => fb ?? _k }),
}));

vi.mock('@salla.sa/twilight-theme-engine/api/product', () => ({
product: {
list: (...args: unknown[]) => listMock(...args),
queries: {
list: (params: { source: string; sourceValue?: unknown }) => ({
queryKey: ['products', 'list', params],
queryFn: () => listMock(params),
}),
},
},
}));

vi.mock('@salla.sa/twilight-theme-engine/common', () => ({
Link: ({ to, children }: { to: string; children?: unknown }) => (
<a href={to}>{children as never}</a>
Expand All @@ -54,14 +56,14 @@ function renderWithClient(ui: React.ReactElement) {
}

beforeEach(() => {
listMock.mockReset();
listMock.mockResolvedValue({
items: [
{ id: 1, name: 'P1' },
{ id: 2, name: 'P2' },
{ id: 3, name: 'P3' },
],
});
requests = [];
useScenario('default');
respondWith(() =>
HttpResponse.json({
data: [1, 2, 3].map((id) => createProduct({ id, name: `P${id}` })),
cursor: { current: 1, next: null, previous: null },
})
);
Element.prototype.scrollBy = vi.fn();
});

Expand All @@ -75,36 +77,38 @@ describe('ProductsSlider', () => {
const { getAllByTestId } = renderWithClient(
<ProductsSlider data={{ products: { source: 'related', source_value: 99 } }} />
);
await waitFor(() =>
expect(listMock).toHaveBeenCalledWith(
expect.objectContaining({ source: 'related', sourceValue: 99 })
)
);
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0].searchParams.get('source')).toBe('related');
expect(requests[0].searchParams.getAll('source_value[]')).toEqual(['99']);
await waitFor(() => expect(getAllByTestId('product-card')).toHaveLength(3));
expect(getAllByTestId('product-card')[0].textContent).toBe('P1');
});

it('asks for the block limit and never shows more, even when the API sends more', async () => {
listMock.mockResolvedValue({
items: Array.from({ length: 12 }, (_, index) => ({ id: index + 1, name: `P${index + 1}` })),
});
respondWith(() =>
HttpResponse.json({
data: Array.from({ length: 12 }, (_, index) =>
createProduct({ id: index + 1, name: `P${index + 1}` })
),
})
);
const { getAllByTestId } = renderWithClient(
<ProductsSlider data={{ products: { source: 'latest' }, limit: 8 }} />
);
await waitFor(() => expect(getAllByTestId('product-card')).toHaveLength(8));
expect(listMock).toHaveBeenCalledWith(expect.objectContaining({ perPage: 8 }));
expect(requests[0].searchParams.get('per_page')).toBe('8');
});

it('shows the skeleton until the products resolve', () => {
listMock.mockReturnValue(new Promise(() => {}));
respondWith(() => new Promise(() => {}));
const { getByTestId } = renderWithClient(
<ProductsSlider data={{ products: { source: 'related', source_value: 1 } }} />
);
expect(getByTestId('list-skeleton')).toBeTruthy();
});

it('removes the whole block (title included) when the source resolves empty', async () => {
listMock.mockResolvedValue({ items: [] });
respondWith(() => HttpResponse.json({ data: [] }));
const { container } = renderWithClient(
<ProductsSlider
data={{ products: { source: 'related', source_value: 1 }, title: 'Similar products' }}
Expand All @@ -114,7 +118,7 @@ describe('ProductsSlider', () => {
});

it('hides the block on a fetch error, without reporting a false onResolve(0)', async () => {
listMock.mockRejectedValue(new Error('network error'));
respondWith(() => HttpResponse.error());
const onResolve = vi.fn();
const { container } = renderWithClient(
<ProductsSlider
Expand Down
47 changes: 47 additions & 0 deletions tests/mocks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Store API mock backend (MSW)

Request handlers, typed factories and named scenarios for the store API the engine calls
(`https://api.salla.dev/store/v1`). Shapes come from `@salla.sa/twilight-theme-engine`
(`dist/types`, `dist/api/*`) — the paths below are the ones its api modules request.

| File | What |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `factories.ts` | `createProduct`, `createSoldOutProduct`, `createOnSaleProduct`, `createProductWithOptions`, `createBookingProduct`, `createDigitalProduct`, `createCategory`, `createBrand`, `createMenuItem`, `createCart`, `createCartItem`, `createArticle`, `createUser`, `createCurrency`, `createStore`. Each takes overrides. |
| `scenarios.ts` | `MockData` and the scenarios `default`, `emptyStore`, `soldOutProduct`, `storeWithBlog`, `multiCurrency`. |
| `handlers.ts` | `createHandlers(data)` and `handlers` (default data). |
| `server.ts` | Node server for vitest (jsdom): `installMockServer()`, `useScenario()`. |
| `browser.ts` | Worker for Storybook / Playwright: `startMockWorker(scenario)`. |

## Use in a unit test

```ts
import { installMockServer, useScenario } from '../mocks/server';

installMockServer(); // starts before the file, resets after each test, fails on unknown requests

it('shows the empty state', async () => {
useScenario('emptyStore');
// ...render; the real engine `product.list()` now gets an empty list
});
```

Override one request with `server.use(http.get(`${STORE_API}/products`, ...))` (see
`tests/components/home/ProductsSlider.test.tsx`). Nothing is global: a test file that
does not call `installMockServer()` is unaffected.

## Handled endpoints

`store/settings`, `products` (sources `latest`, `categories`, `brands`, `selected`, `offers`,
`search`; `page`/`cursor`, `per_page`, `sort=price|-price|name|-name`), `products/:id/details`,
`products/tags/:id`, `categories`, `categories/:id`, `brands`, `brands/:id`, `menus/header`,
`menus/footer`, `blog`, `blog/articles`, `blog/articles/:id`, `cart/:id`, `auth/user`.

## Not confirmed / not covered

- Testimonials, reviews, orders, wallet, loyalty, notifications, pages and `component/list`
have no handler: the engine's response shapes for them were not checked, so none were invented.
- Real `sort` values and the exact real `cursor.next` format were not confirmed; the mock
supports the four sorts above and uses the next page number as an opaque cursor.
- `store/settings` returns only `store`, `currencies`, `languages`, `user`; `createStore`
fills the fields the theme reads, not every required field of the engine's `Store`.
- Browser worker: run `npx msw init public/ --save` when wiring Storybook or Playwright.
11 changes: 11 additions & 0 deletions tests/mocks/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { setupWorker } from 'msw/browser';
import { createHandlers } from './handlers';
import { scenarios, type ScenarioName } from './scenarios';

/**
* Browser worker for Storybook and Playwright. Not used by vitest.
* The worker script is generated into the app's public dir with `npx msw init public/ --save`
* by whichever task wires Storybook or Playwright; it is not committed here.
*/
export const startMockWorker = (scenario: ScenarioName = 'default') =>
setupWorker(...createHandlers(scenarios[scenario]())).start({ onUnhandledRequest: 'bypass' });
Loading
Loading