feat: proklik na stránky podniku ze stránky objednávek
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 37s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 39s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-10 19:09:20 +02:00
parent cc09ddbd2c
commit 88b9e20e2d
11 changed files with 154 additions and 36 deletions
+3
View File
@@ -0,0 +1,3 @@
[
"Proklik na nabídku podniku ze stránky objednávek"
]
+2 -2
View File
@@ -49,11 +49,11 @@ export async function getOrderDates(): Promise<string[]> {
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores();
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
if (!stores.some(s => s.name.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
const canonical = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase())!.name;
const group: OrderGroup = {
id: crypto.randomUUID(),
name: canonical,
+5 -2
View File
@@ -11,15 +11,18 @@ router.get("/", async (_req, res, next) => {
});
router.post("/add", async (req, res, next) => {
const { name, heslo } = req.body ?? {};
const { name, heslo, url } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
if (url != null && typeof url !== 'string') {
return res.status(400).json({ error: 'Neplatná URL obchodu' });
}
try {
const stores = await addStore(name, heslo);
const stores = await addStore(name, heslo, url);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
+43 -7
View File
@@ -1,13 +1,29 @@
import { Store } from "../../types/gen/types.gen";
import getStorage from "./storage";
const storage = getStorage();
const STORES_KEY = 'stores';
export async function getStores(): Promise<string[]> {
return (await storage.getData<string[]>(STORES_KEY)) ?? [];
/**
* Vrátí seznam povolených obchodů. Zachovává zpětnou kompatibilitu se starším
* formátem, kdy byly obchody uloženy jako pole řetězců (převede je na objekty).
*/
export async function getStores(): Promise<Store[]> {
const raw = await storage.getData<(string | Store)[]>(STORES_KEY);
if (!raw) {
return [];
}
return raw.map(s => (typeof s === 'string' ? { name: s } : s));
}
export async function addStore(name: string, heslo: string): Promise<string[]> {
/**
* Přidá nový obchod do seznamu povolených.
*
* @param name název obchodu
* @param heslo admin heslo
* @param url volitelná URL na nabídku podniku
*/
export async function addStore(name: string, heslo: string, url?: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
@@ -17,21 +33,41 @@ export async function addStore(name: string, heslo: string): Promise<string[]> {
throw new Error('Název obchodu nesmí být prázdný');
}
const stores = await getStores();
if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) {
if (stores.some(s => s.name.toLowerCase() === trimmed.toLowerCase())) {
throw new Error('Obchod s tímto názvem již existuje');
}
const updated = [...stores, trimmed];
const trimmedUrl = url?.trim();
if (trimmedUrl) {
// Povolíme pouze http(s), aby URL nemohla být zneužita (např. javascript: → XSS)
let parsed: URL;
try {
parsed = new URL(trimmedUrl);
} catch {
throw new Error('Neplatná URL obchodu');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('URL musí začínat http:// nebo https://');
}
}
const store: Store = trimmedUrl ? { name: trimmed, url: trimmedUrl } : { name: trimmed };
const updated = [...stores, store];
await storage.setData(STORES_KEY, updated);
return updated;
}
export async function removeStore(name: string, heslo: string): Promise<string[]> {
/**
* Odebere obchod ze seznamu povolených (dle názvu).
*
* @param name název obchodu
* @param heslo admin heslo
*/
export async function removeStore(name: string, heslo: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const stores = await getStores();
const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase());
const updated = stores.filter(s => s.name.toLowerCase() !== name.trim().toLowerCase());
await storage.setData(STORES_KEY, updated);
return updated;
}
+35 -6
View File
@@ -1,8 +1,12 @@
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { getStores, addStore, removeStore } from '../stores';
const ADMIN_PW = 'testadmin';
/** Vrátí pole názvů obchodů pro snadné porovnání v testech. */
const names = (stores: { name: string }[]) => stores.map(s => s.name);
beforeEach(() => {
resetMemoryStorage();
process.env.ADMIN_PASSWORD = ADMIN_PW;
@@ -17,12 +21,37 @@ describe('getStores', () => {
const stores = await getStores();
expect(stores).toEqual([]);
});
test('převede starý formát (pole řetězců) na objekty', async () => {
// Simulace dat uložených ve starším formátu (před zavedením URL)
await getStorage().setData('stores', ['McDonald\'s', 'KFC']);
const stores = await getStores();
expect(stores).toEqual([{ name: 'McDonald\'s' }, { name: 'KFC' }]);
});
});
describe('addStore', () => {
test('přidá obchod se správným heslem', async () => {
const stores = await addStore('McDonald\'s', ADMIN_PW);
expect(stores).toContain('McDonald\'s');
expect(names(stores)).toContain('McDonald\'s');
});
test('uloží volitelnou URL a ořízne mezery', async () => {
const stores = await addStore('Bistro', ADMIN_PW, ' https://wolt.com/bistro ');
expect(stores).toContainEqual({ name: 'Bistro', url: 'https://wolt.com/bistro' });
});
test('bez URL uloží obchod bez pole url', async () => {
const stores = await addStore('KFC', ADMIN_PW, ' ');
expect(stores).toContainEqual({ name: 'KFC' });
});
test('odmítne URL s nepovoleným schématem (javascript:)', async () => {
await expect(addStore('Zlo', ADMIN_PW, 'javascript:alert(1)')).rejects.toThrow('http');
});
test('odmítne nevalidní URL', async () => {
await expect(addStore('Zlo', ADMIN_PW, 'nfdjska')).rejects.toThrow('Neplatná URL');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
@@ -47,8 +76,8 @@ describe('addStore', () => {
await addStore('McDonald\'s', ADMIN_PW);
const stores = await addStore('KFC', ADMIN_PW);
expect(stores).toHaveLength(2);
expect(stores).toContain('McDonald\'s');
expect(stores).toContain('KFC');
expect(names(stores)).toContain('McDonald\'s');
expect(names(stores)).toContain('KFC');
});
});
@@ -59,12 +88,12 @@ describe('removeStore', () => {
test('odebere obchod se správným heslem', async () => {
const stores = await removeStore('McDonald\'s', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s');
expect(names(stores)).not.toContain('McDonald\'s');
});
test('case-insensitive odebrání', async () => {
const stores = await removeStore('MCDONALD\'S', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s');
expect(names(stores)).not.toContain('McDonald\'s');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
@@ -73,6 +102,6 @@ describe('removeStore', () => {
test('odebrání neexistujícího obchodu nic nerozbije', async () => {
const stores = await removeStore('Neexistuje', ADMIN_PW);
expect(stores).toContain('McDonald\'s');
expect(names(stores)).toContain('McDonald\'s');
});
});