import { test, expect, type Page } from '@playwright/test'; import { ROUTES, listingPath, negotiationPath, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath, paymentPath, priceHistoryPath } from '../src/routes'; // Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router, // a nawigacja odbywa sie przez /. Te testy pilnuja tej zasady. const ADMIN = { email: 'admin@softspm.com', password: 'Admin123!' }; const PUBLIC_ROUTES = [ ROUTES.home, ROUTES.buy, ROUTES.rent, ROUTES.sell, ROUTES.valuation, ROUTES.priceHistory, ROUTES.districtRanking, ROUTES.negotiation, ROUTES.creditCalculator, ROUTES.map, ROUTES.services, ROUTES.companies, ROUTES.guides, ROUTES.guideBuying, ROUTES.login, ROUTES.loginForgot, ROUTES.register, ROUTES.registerPrivate, ROUTES.registerCompany, ]; const PROTECTED_ROUTES = [ ROUTES.account, ROUTES.favorites, ROUTES.messages, conversationPath(ADMIN_CONVERSATION_SLUG), newConversationPath(1), paymentPath(1), ROUTES.add, ROUTES.admin, `${ROUTES.admin}/leady`, ]; async function goto(page: Page, path: string) { await page.goto(path, { waitUntil: 'domcontentloaded' }); await page.locator('main').first().waitFor({ state: 'attached' }); // ProtectedRoute dociaga profil z /auth/me - poczekaj, az skonczy stan ladowania await page.locator('.route-loading').waitFor({ state: 'detached' }).catch(() => {}); await page.waitForTimeout(400); } async function login(page: Page) { await goto(page, ROUTES.login); await page.getByPlaceholder('Wpisz swój adres e-mail').fill(ADMIN.email); await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password); await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click(); // token w localStorage to pewny dowod zalogowania - adres moze sie jeszcze zmieniac await page.waitForFunction(() => Boolean(window.localStorage.getItem('polskalokalnie-auth-token'))); await page.waitForFunction(() => window.location.pathname !== '/logowanie'); } test.describe('Trasy publiczne', () => { for (const path of PUBLIC_ROUTES) { test(`bezposrednie wejscie na ${path} (F5 / nowa karta / wklejony link)`, async ({ page }) => { await goto(page, path); expect(new URL(page.url()).pathname).toBe(path); await expect(page.locator('main')).not.toBeEmpty(); }); } test('nieznany adres pokazuje strone 404', async ({ page }) => { await goto(page, '/adres-ktory-nie-istnieje'); await expect(page.locator('.not-found-page')).toBeVisible(); }); test('odswiezenie strony zachowuje widok', async ({ page }) => { await goto(page, ROUTES.districtRanking); await page.reload({ waitUntil: 'domcontentloaded' }); expect(new URL(page.url()).pathname).toBe(ROUTES.districtRanking); }); }); test.describe('Lista firm i uslug (/firmy)', () => { // Do serwisu nie dolaczyla jeszcze zadna firma. Dopoki tak jest, /firmy pokazuje SAMO zaproszenie - // bez naglowka, licznika, chipow kategorii i wyszukiwarki (pokazywalyby zera i filtrowaly pustke). // Gdy pojawi sie pierwsza firma, gorna czesc wraca i te testy trzeba rozszerzyc o karty. test('wejscie na /firmy pokazuje samo zaproszenie', async ({ page }) => { await goto(page, ROUTES.companies); await expect(page.locator('.companies-none')).toBeVisible(); await expect(page.locator('.directory-card')).toHaveCount(0); await expect(page.locator('.companies-head')).toHaveCount(0); await expect(page.locator('.companies-filters')).toHaveCount(0); await expect(page.locator('.service-mini-search')).toHaveCount(0); }); test('zaproszenie przejmuje role naglowka H1', async ({ page }) => { await goto(page, ROUTES.companies); const headings = page.locator('.companies-page h1'); await expect(headings).toHaveCount(1); await expect(headings).toContainText('Nie mamy jeszcze żadnych firm'); }); test('kategoria z query stringu trafia do komunikatu', async ({ page }) => { await goto(page, `${ROUTES.companies}?kategoria=remonty`); await expect(page.locator('.companies-none')).toContainText('Remont i wykończenie'); await goto(page, ROUTES.companies); await expect(page.locator('.companies-none')).toContainText('w naszej bazie'); }); test('nieznana kategoria nie wywraca widoku', async ({ page }) => { await goto(page, `${ROUTES.companies}?kategoria=nie-ma-takiej`); await expect(page.locator('.companies-none')).toBeVisible(); await expect(page.locator('.companies-none')).toContainText('w naszej bazie'); }); // Kategorii usług jeszcze nie obsługujemy, więc kafelek prowadzi na stronę zgłoszenia // reklamowego (/reklama), a nie do listy firm - z kategorią przeniesioną w query stringu. test('"Zobacz firmy" prowadzi na strone reklamy z wybrana kategoria', async ({ page }) => { await goto(page, ROUTES.services); const link = page.locator('.service-category-card', { hasText: 'Remont i wykończenie' }).getByRole('link'); await expect(link).toHaveAttribute('href', '/reklama?kategoria=remonty'); await link.click(); await page.waitForFunction(() => window.location.pathname === '/reklama'); await expect(page.locator('.advertising-input.select select')).toHaveValue('Remont i wykończenie'); }); test('"Zobacz wszystkie" prowadzi na /firmy', async ({ page }) => { await goto(page, ROUTES.services); const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link'); await expect(link).toHaveAttribute('href', ROUTES.companies); await link.click(); await page.waitForFunction(() => window.location.pathname === '/firmy'); await expect(page.locator('.companies-none')).toBeVisible(); }); test('Wstecz i Dalej dzialaja na filtrze kategorii', async ({ page }) => { await goto(page, ROUTES.services); await page.locator('.service-category-card', { hasText: 'Finanse' }).getByRole('link').click(); await page.waitForFunction(() => window.location.search.includes('kategoria=finanse')); await page.goBack({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.location.pathname === '/firmy-i-uslugi'); await page.goForward({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.location.search.includes('kategoria=finanse')); }); }); test.describe('Nawigacja przez linki', () => { test('menu glowne to - mozna otworzyc w nowej karcie', async ({ page }) => { await goto(page, ROUTES.home); for (const [label, expected] of [ ['Kupuję', ROUTES.buy], ['Wynajmuję', ROUTES.rent], ['Poradniki', ROUTES.guides], ['Firmy i usługi', ROUTES.services], ] as const) { const link = page.getByRole('link', { name: label, exact: true }).first(); await expect(link).toHaveAttribute('href', expected); } }); test('aktywna pozycja menu wynika z adresu', async ({ page }) => { await goto(page, ROUTES.buy); await expect(page.getByRole('link', { name: 'Kupuję', exact: true }).first()).toHaveClass(/active/); }); test('CTA naglowka maja poprawne adresy', async ({ page }) => { await goto(page, ROUTES.home); await expect(page.locator('a.post-button')).toHaveAttribute('href', ROUTES.add); await expect(page.getByRole('link', { name: 'Moje konto' })).toHaveAttribute('href', ROUTES.login); }); test('CTA w tresci strony sa linkami z href', async ({ page }) => { await goto(page, ROUTES.sell); await expect(page.locator('a.sell-primary')).toHaveAttribute('href', ROUTES.add); await expect(page.locator('.sell-valuation-card a')).toHaveAttribute('href', ROUTES.valuation); }); test('karta oferty ma prawdziwy link do strony oferty', async ({ page }) => { await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const card = page.locator('.real-result-card').first(); const href = await card.locator('.card-stretched-link').getAttribute('href'); expect(href).toMatch(/^\/oferta\/\d+$/); await expect(card.locator('a.real-card-cta')).toHaveAttribute('href', href!); }); test('klik w serce na karcie nie nawiguje', async ({ page }) => { await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); await page.locator('.real-result-card').first().locator('.real-favorite-button').click(); await page.waitForTimeout(700); expect(new URL(page.url()).pathname).toBe(ROUTES.buy); }); test('klik w menu zmienia adres, Wstecz i Dalej dzialaja', async ({ page }) => { await goto(page, ROUTES.home); await page.getByRole('link', { name: 'Kupuję', exact: true }).first().click(); await page.waitForFunction(() => window.location.pathname === '/kupno'); await page.getByRole('link', { name: 'Poradniki', exact: true }).first().click(); await page.waitForFunction(() => window.location.pathname === '/poradniki'); await page.goBack({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.location.pathname === '/kupno'); await page.goForward({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.location.pathname === '/poradniki'); }); }); test.describe('Standard: brak atrap linkow', () => { const PAGES = [ROUTES.home, ROUTES.services, ROUTES.companies, ROUTES.guides, ROUTES.sell, ROUTES.buy]; for (const path of PAGES) { test(`${path} nie zawiera href="#" ani javascript:void`, async ({ page }) => { await goto(page, path); const bad = await page.locator('a[href="#"], a[href^="javascript:"]').count(); expect(bad).toBe(0); }); } }); test.describe('Trasy chronione', () => { test('bez logowania przekierowuja na /logowanie', async ({ page }) => { for (const path of PROTECTED_ROUTES) { await goto(page, path); expect(new URL(page.url()).pathname).toBe(ROUTES.login); } }); test('po zalogowaniu wracamy na zamierzona strone', async ({ page }) => { await goto(page, ROUTES.accountPriceAlerts); expect(new URL(page.url()).pathname).toBe(ROUTES.login); await page.getByPlaceholder('Wpisz swój adres e-mail').fill(ADMIN.email); await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password); await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click(); await page.waitForFunction(() => window.location.pathname === '/konto/alerty-cenowe'); }); test('zakladki panelu admina maja wlasne adresy i przezywaja F5', async ({ page }) => { await login(page); await goto(page, ROUTES.admin); await page.locator('.admin-nav a', { hasText: 'Leady' }).first().click(); await page.waitForFunction(() => window.location.pathname === '/admin/leady'); await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('.admin-nav').first().waitFor({ state: 'attached' }); expect(new URL(page.url()).pathname).toBe('/admin/leady'); }); test('nieznana sekcja panelu admina pokazuje 404', async ({ page }) => { await login(page); await goto(page, `${ROUTES.admin}/nie-ma-takiej-sekcji`); await expect(page.locator('.not-found-page')).toBeVisible(); }); }); test.describe('Layout konta', () => { // Kazda pozycja lewego menu ma otwierac sie tak samo jak /konto/wyszukiwania, // czyli razem z widocznym menu konta. const ACCOUNT_PAGES = [ ROUTES.account, ROUTES.accountSearches, ROUTES.favorites, ROUTES.accountPriceAlerts, ROUTES.comparison, ROUTES.messages, ROUTES.accountMeetings, ROUTES.accountListings, ROUTES.accountSettings, ROUTES.notifications, ROUTES.accountHelpContact, ]; // Narzedzia publiczne dzialaja jak /kupno czy /wynajem - bez menu konta. const PUBLIC_TOOLS = [ROUTES.valuation, ROUTES.priceHistory, ROUTES.districtRanking, ROUTES.negotiation]; test('lewe menu konta jest widoczne na wszystkich stronach konta', async ({ page }) => { await login(page); for (const path of ACCOUNT_PAGES) { await goto(page, path); await expect(page.locator('.account-sidebar'), `brak lewego menu na ${path}`).toBeVisible(); } }); test('lewe menu nie przeskakuje miedzy stronami konta', async ({ page }) => { await login(page); const positions: Array<{ path: string; x: number; width: number }> = []; for (const path of ACCOUNT_PAGES) { await goto(page, path); const box = await page.locator('.account-sidebar').first().boundingBox(); positions.push({ path, x: Math.round(box!.x), width: Math.round(box!.width) }); } const first = positions[0]; for (const pos of positions) { expect(pos.x, `menu przesuniete na ${pos.path}`).toBe(first.x); expect(pos.width, `inna szerokosc menu na ${pos.path}`).toBe(first.width); } }); // Narzedzia sa publiczne: gosc widzi sama strone, a zalogowany takze lewe menu konta - // dzieki temu menu nie znika po otwarciu narzedzia z panelu. test('narzedzia publiczne: bez menu dla goscia, z menu po zalogowaniu', async ({ page }) => { for (const path of PUBLIC_TOOLS) { await goto(page, path); await expect(page.locator('.account-sidebar'), `menu konta u goscia na ${path}`).toHaveCount(0); } await login(page); for (const path of PUBLIC_TOOLS) { await goto(page, path); await expect(page.locator('.account-sidebar'), `brak menu konta na ${path}`).toBeVisible(); } }); // Bezpieczenstwo i edycja profilu byly osobnymi stronami powtarzajacymi tresc ustawien. // Teraz sa sekcjami /konto/ustawienia, a stare adresy maja przekierowywac. test('stare adresy profilu i bezpieczenstwa przekierowuja do ustawien', async ({ page }) => { await login(page); for (const path of [ROUTES.accountSecurity, ROUTES.accountProfileEdit]) { await goto(page, path); expect(new URL(page.url()).pathname, `${path} nie przekierowal`).toBe(ROUTES.accountSettings); } }); test('ustawienia pokazuja dane podane przy rejestracji i nie dubluja sekcji', async ({ page }) => { await login(page); await goto(page, ROUTES.accountSettings); // E-mail konta jest wypelniony automatycznie i nie da sie go tu zmienic. const emailInput = page.locator('.settings-card input[disabled]').first(); await expect(emailInput).toHaveValue(ADMIN.email); // Nick nadany przy zakladaniu konta jest widoczny w formularzu. await expect(page.getByText('polskalokalnie.pl/u/', { exact: false }).first()).toBeVisible(); // Jezyk komunikacji wystepowal wczesniej dwa razy na jednym ekranie. await expect(page.getByText('Język komunikacji', { exact: true })).toHaveCount(1); // Bezpieczenstwo jest dokladnie jedna sekcja, nie karta + osobna strona. await expect(page.locator('section[aria-label="Bezpieczeństwo"]')).toHaveCount(1); }); test('menu uzytkownika w naglowku ma pozycje ulozone w jednej linii', async ({ page }) => { await login(page); await goto(page, ROUTES.home); await page.locator('.account-menu-trigger').click(); const items = page.locator('.account-popover a'); await expect(items.first()).toBeVisible(); for (let i = 0; i < await items.count(); i += 1) { const box = await items.nth(i).boundingBox(); // ikona obok tekstu daje ~42px; zawiniecie do dwoch linii daje wyraznie wiecej expect(Math.round(box!.height), 'pozycja menu zawinela sie do dwoch linii').toBeLessThan(56); } }); }); test.describe('Naglowek', () => { test('zostaje przyklejony do gory przy przewijaniu', async ({ page }) => { for (const path of [ROUTES.home, ROUTES.buy, ROUTES.guides]) { await goto(page, path); await page.evaluate(() => window.scrollTo(0, 1400)); await page.waitForTimeout(300); const box = await page.locator('.header').boundingBox(); expect(Math.round(box!.y), `naglowek odjechal na ${path}`).toBe(0); } }); test('logo prowadzi na strone glowna i pokazuje nowa nazwe', async ({ page }) => { await goto(page, ROUTES.buy); const brand = page.locator('a.brand'); await expect(brand).toHaveAttribute('href', ROUTES.home); await expect(brand).toContainText('Polska Lokalnie'); await expect(brand).toContainText('ogłoszenia za darmo'); }); }); test.describe('Wiadomosci', () => { test('"Napisz wiadomosc" w ogloszeniu otwiera rozmowe pod wlasnym adresem', async ({ page }) => { await login(page); await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); await goto(page, href!); await page.getByRole('button', { name: /Napisz wiadomość/ }).first().click(); await page.waitForFunction(() => /^\/wiadomosci\/nowa\/\d+$/.test(window.location.pathname)); // Adres szkicu dziala po odswiezeniu - rozmowa nie zalezy od stanu nawigacji. await page.reload(); await expect(page.locator('.messages-composer input[type=text]')).toBeVisible(); }); test('nieistniejaca rozmowa wraca na liste wiadomosci', async ({ page }) => { await login(page); await goto(page, conversationPath(999999)); await page.waitForFunction(() => window.location.pathname === '/wiadomosci'); }); }); test.describe('Logowanie i rejestracja', () => { test('kroki maja wlasne adresy, a Wstecz wraca do poprzedniego kroku', async ({ page }) => { await goto(page, ROUTES.login); await page.getByRole('button', { name: 'Zarejestruj się' }).first().click(); await page.waitForFunction(() => window.location.pathname === '/rejestracja'); await page.locator('.register-choice-option').first().click(); await page.waitForFunction(() => window.location.pathname === '/rejestracja/osoba-prywatna'); await page.goBack(); await page.waitForFunction(() => window.location.pathname === '/rejestracja'); }); test('ekran "sprawdz poczte" bez rejestracji wraca do rejestracji', async ({ page }) => { await goto(page, ROUTES.registerPending); await page.waitForFunction(() => window.location.pathname === '/rejestracja'); }); }); test.describe('Wycena', () => { test('kroki maja wlasne adresy, a dane przetrwaja odswiezenie', async ({ page }) => { await goto(page, ROUTES.valuation); await page.locator('.valuation-form-section input').first().fill('Warszawa'); await page.getByRole('button', { name: /Dalej/ }).click(); await page.waitForFunction(() => window.location.pathname === '/wycena/szczegoly'); await page.reload(); await page.goBack(); await page.waitForFunction(() => window.location.pathname === '/wycena'); await expect(page.locator('.valuation-form-section input').first()).toHaveValue('Warszawa'); }); test('pusty wynik prowadzi do kroku z brakujacymi danymi', async ({ page }) => { await goto(page, ROUTES.valuationResult); await expect(page.locator('.valuation-result-hero strong').first()).toHaveText('Brakuje danych do wyceny'); await page.locator('.valuation-missing-item').first().click(); await page.waitForFunction(() => window.location.pathname === '/wycena'); }); }); test.describe('Negocjacje', () => { test('"Ile moge utargowac?" w ogloszeniu sprzedazy otwiera analize pod adresem z numerem', async ({ page }) => { await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); await goto(page, href!); await page.getByRole('link', { name: /Ile mogę utargować/ }).click(); await page.waitForFunction(() => /^\/negocjacje\/\d+$/.test(window.location.pathname)); await page.reload(); await expect(page.locator('.negotiation-offer h2')).toBeVisible(); await expect(page.locator('.negotiation-range')).not.toContainText('Pobieramy', { timeout: 15000 }); }); test('nieistniejace ogloszenie pokazuje komunikat', async ({ page }) => { await goto(page, negotiationPath(999999)); await expect(page.getByText('Nie znaleźliśmy tego ogłoszenia')).toBeVisible(); }); }); test.describe('Standard: przyciski robia to, co obiecuja', () => { test('wyszukiwarka poradnikow trzyma fraze w adresie i przezywa F5', async ({ page }) => { await goto(page, ROUTES.guides); await page.locator('.guides-search input').fill('kredyt'); await page.locator('.guides-search button').click(); await page.waitForFunction(() => window.location.search.includes('szukaj=kredyt')); await page.reload(); await expect(page.locator('section[aria-label="Wyniki wyszukiwania"]')).toContainText('kredyt'); }); test('"Szukaj na biezacej mapie" zaweza liste do ofert z mapy', async ({ page }) => { await goto(page, ROUTES.buy); await page.locator('.map-card').first().waitFor(); await page.getByRole('button', { name: /Szukaj na bieżącej mapie/ }).first().click(); await page.waitForFunction(() => window.location.search.includes('obszar=')); await expect(page.locator('.map-area-note')).toBeVisible(); await page.locator('.map-area-note button').click(); await page.waitForFunction(() => !window.location.search.includes('obszar=')); }); test('porownanie trzyma wybrane oferty w adresie', async ({ page }) => { await login(page); await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); const listingId = Number(href!.split('/').pop()); await goto(page, `${ROUTES.comparison}?oferty=${listingId}`); await expect(page.locator('.comparison-main')).toContainText('Zobacz ogłoszenie'); await page.reload(); await expect(page.locator('.comparison-main')).toContainText('Zobacz ogłoszenie'); }); test('kanaly kontaktu w pomocy to prawdziwe linki mailto i tel', async ({ page }) => { await login(page); await goto(page, ROUTES.accountHelpContact); await expect(page.locator('a.help-contact-channel-action[href^="mailto:"]')).toHaveCount(1); await expect(page.locator('a.help-contact-channel-action[href^="tel:"]')).toHaveCount(1); }); }); // Zapisane wyszukiwania trzyma serwer, wiec sa te same w kazdej przegladarce i przezywaja // wyczyszczenie danych strony. Bez powiadomien - to tylko lista filtrow do wygodnego powrotu. test.describe('Zapisane wyszukiwania', () => { test('zapis przezywa wyczyszczenie pamieci przegladarki', async ({ page }) => { await login(page); await goto(page, `${ROUTES.buy}?miasto=Gdynia`); const saveButton = page.getByRole('button', { name: /wyszukiwanie/ }).first(); if ((await saveButton.innerText()).includes('Zapisano')) { await saveButton.click(); await expect(saveButton).toHaveText(/Zapisz wyszukiwanie/); } await saveButton.click(); await expect(saveButton).toHaveText(/Zapisano wyszukiwanie/); // Symulacja innego urzadzenia: zostaje sam token logowania, reszta pamieci znika. await page.evaluate(() => { const token = window.localStorage.getItem('polskalokalnie-auth-token'); window.localStorage.clear(); if (token) { window.localStorage.setItem('polskalokalnie-auth-token', token); } }); await goto(page, ROUTES.accountSearches); await expect(page.locator('.account-searches-list, main').first()).toContainText('Gdynia'); // Sprzatanie, zeby kolejne uruchomienie testu zaczynalo od czystej listy. await page.locator('.account-search-remove').first().click(); await expect(page.locator('main')).not.toContainText('Gdynia'); }); }); // Spotkania sa na koncie, a ogladanie umawiaja obie strony rozmowy o ogloszeniu. test.describe('Spotkania', () => { test('wlasny wpis w kalendarzu przezywa odswiezenie', async ({ page }) => { await login(page); await goto(page, ROUTES.accountMeetings); await page.getByRole('button', { name: /Dodaj własny wpis/ }).click(); const title = `Notatka ${Date.now()}`; await page.locator('.meetings-create-form input[type=text]').first().fill(title); await page.getByRole('button', { name: 'Zapisz wpis' }).click(); await expect(page.locator('.meetings-list')).toContainText(title); await page.reload(); await expect(page.locator('.meetings-list')).toContainText(title); await page.locator('button[aria-label="Usuń wpis"]').first().click(); await expect(page.locator('.meetings-list')).not.toContainText(title); }); test('w rozmowie o ogloszeniu mozna zaproponowac ogladanie', async ({ page }) => { await login(page); await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); await goto(page, href!); await page.getByRole('button', { name: /Napisz wiadomość/ }).first().click(); await page.waitForFunction(() => window.location.pathname.startsWith('/wiadomosci/')); await page.locator('.messages-composer input[type=text]').fill('Dzien dobry, czy oferta jest aktualna?'); await page.locator('.messages-composer .send').click(); await page.waitForFunction(() => /wiadomosci/.test(window.location.pathname) && window.location.pathname.split('/').length === 3); await page.getByRole('button', { name: /Zaproponuj oglądanie/ }).click(); await expect(page.locator('.messages-meeting-form')).toBeVisible(); }); }); // Ulubione sa na koncie. Test pilnuje regresji: wejscie z pustej przegladarki NIE moze // skasowac ulubionych zapisanych wczesniej (kiedys front dyktowal serwerowi cala liste). test.describe('Ulubione', () => { test('przezywaja wyczyszczenie pamieci przegladarki', async ({ page }) => { await login(page); await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); await goto(page, href!); const title = await page.locator('.listing-detail-title, h1').first().innerText(); const heart = page.locator('button[aria-label*="ulubion"], .listing-detail-fav').first(); await heart.click(); await goto(page, ROUTES.favorites); await expect(page.locator('main')).toContainText(title.trim().slice(0, 20)); await page.evaluate(() => { const token = window.localStorage.getItem('polskalokalnie-auth-token'); window.localStorage.clear(); if (token) { window.localStorage.setItem('polskalokalnie-auth-token', token); } }); await goto(page, ROUTES.favorites); await expect(page.locator('main')).toContainText(title.trim().slice(0, 20)); await page.locator('button[aria-label*="Usuń"], .favorites-card-remove').first().click(); await expect(page.locator('main')).not.toContainText(title.trim().slice(0, 20)); }); }); // Lista sprawdzonych lokalizacji w Historii cen jest ustawieniem konta, nie pamiecia przegladarki. test.describe('Historia cen', () => { test('sprawdzone lokalizacje przezywaja wyczyszczenie pamieci przegladarki', async ({ page }) => { await login(page); await goto(page, ROUTES.priceHistory); await page.locator('.history-location-display').click(); await page.locator('.history-location-pop input').fill('Kraków'); await page.locator('.history-location-pop button', { hasText: 'Kraków' }).first().click(); await expect(page.locator('main')).toContainText('Kraków'); await page.evaluate(() => { const token = window.localStorage.getItem('polskalokalnie-auth-token'); window.localStorage.clear(); if (token) { window.localStorage.setItem('polskalokalnie-auth-token', token); } }); await goto(page, ROUTES.priceHistory); await expect(page.locator('main')).toContainText('Kraków'); }); }); // Alerty cenowe sa na koncie: zmiany wykrywa serwer, a powiadomienie trafia na liste w serwisie. test.describe('Alerty cenowe', () => { test('alert ogloszenia przezywa wyczyszczenie pamieci przegladarki', async ({ page }) => { await login(page); await goto(page, ROUTES.buy); await page.locator('.real-result-card').first().waitFor(); const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); await goto(page, href!); const alertButton = page.locator('.listing-detail-alert'); if ((await alertButton.innerText()).includes('aktywny')) { await alertButton.click(); await expect(alertButton).toHaveText(/Utwórz alert ceny/); } await alertButton.click(); await expect(alertButton).toHaveText(/Alert aktywny/); // Symulacja innego urzadzenia: zostaje sam token logowania. await page.evaluate(() => { const token = window.localStorage.getItem('polskalokalnie-auth-token'); window.localStorage.clear(); if (token) { window.localStorage.setItem('polskalokalnie-auth-token', token); } }); await goto(page, ROUTES.accountPriceAlerts); await expect(page.locator('.price-alerts-list-section')).not.toContainText('Nie obserwujesz jeszcze żadnego ogłoszenia'); // Sprzatanie: alert zdejmujemy tam, gdzie zostal zalozony. await goto(page, href!); await page.locator('.listing-detail-alert').click(); await expect(page.locator('.listing-detail-alert')).toHaveText(/Utwórz alert ceny/); }); }); // RODO: bez zgody nie ladujemy tresci z obcych serwisow, ktore dostalyby adres IP ogladajacego. test.describe('Prywatnosc', () => { test('wirtualny spacer laduje sie dopiero po zgodzie', async ({ page }) => { const externalHosts = new Set(); page.on('request', (request) => { const host = new URL(request.url()).hostname; if (!host.includes('localhost') && !host.includes('127.0.0.1')) { externalHosts.add(host); } }); // Szukamy ogloszenia ze spacerem - tylko tam jest co blokowac. const listings = await page.request.get('/api/listings?offerType=SALE').then((r) => r.json()); let tourListingId: number | null = null; for (const item of listings) { const detail = await page.request.get(`/api/listings/${item.id}`).then((r) => r.json()); if (detail.virtualTourUrl) { tourListingId = item.id; break; } } if (tourListingId == null) { test.skip(true, 'Zadne ogloszenie nie ma wirtualnego spaceru'); } await goto(page, listingPath(tourListingId!)); await expect(page.locator('.listing-detail-tour-consent')).toBeVisible(); await expect(page.locator('.listing-detail-tour-embed iframe')).toHaveCount(0); expect([...externalHosts].some((host) => host.includes('youtube') || host.includes('kuula') || host.includes('matterport'))).toBe(false); await page.getByRole('button', { name: /Pokaż wirtualny spacer/ }).click(); await expect(page.locator('.listing-detail-tour-embed iframe')).toHaveCount(1); }); }); // Telefon: poziome menu nie miesci sie w naglowku, wiec dzialy sa w szufladzie, // a najczestsze miejsca w dolnym pasku. Kazda pozycja to link ze zmiana adresu. test.describe('Nawigacja mobilna', () => { test.use({ viewport: { width: 390, height: 844 } }); test('naglowek nie przewija sie w bok, menu glowne chowa sie w szufladzie', async ({ page }) => { await goto(page, ROUTES.home); await expect(page.locator('.nav')).toBeHidden(); const overflow = await page.evaluate(() => { const de = document.documentElement; return de.scrollWidth - de.clientWidth; }); expect(overflow).toBeLessThanOrEqual(0); await page.getByRole('button', { name: 'Menu serwisu' }).click(); await expect(page.locator('.mobile-menu')).toBeVisible(); await page.locator('.mobile-menu').getByRole('link', { name: 'Wynajmuję' }).click(); await expect(page).toHaveURL(new RegExp(`${ROUTES.rent}$`)); await expect(page.locator('.mobile-menu')).toHaveCount(0); }); test('dolny pasek prowadzi pod wlasne adresy', async ({ page }) => { await goto(page, ROUTES.home); // Baner zgody stoi nad dolnym paskiem - na telefonie trzeba go najpierw zamknac. await page.getByRole('button', { name: 'Tylko niezbędne' }).click(); const bar = page.locator('.mobile-nav'); await expect(bar).toBeVisible(); await bar.getByRole('link', { name: 'Szukaj' }).click(); await expect(page).toHaveURL(new RegExp(`${ROUTES.buy}$`)); await bar.getByRole('link', { name: 'Dodaj ogłoszenie' }).click(); // Dodawanie wymaga logowania - goscia przejmuje ProtectedRoute. await expect(page).toHaveURL(new RegExp(`${ROUTES.login}$`)); await bar.getByRole('link', { name: 'Start' }).click(); await expect(page).toHaveURL(/\/$/); }); test('dolny pasek nie zaslania stopki', async ({ page }) => { await goto(page, ROUTES.buy); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(400); const footerBottom = await page.locator('.site-footer').evaluate((el) => el.getBoundingClientRect().bottom); const barTop = await page.locator('.mobile-nav').evaluate((el) => el.getBoundingClientRect().top); expect(footerBottom).toBeLessThanOrEqual(barTop + 1); }); test('na desktopie nie ma dolnego paska ani przycisku menu', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await goto(page, ROUTES.home); await expect(page.locator('.mobile-nav')).toBeHidden(); await expect(page.locator('.header-burger')).toBeHidden(); await expect(page.locator('.nav')).toBeVisible(); }); }); // Dzielnica bez wlasnych ogloszen nadal ma na wykresie ceny transakcyjne dla calego miasta - // naglowek i rubryki licza wtedy srednia z NBP i GUS, zamiast pokazywac same kreski. test.describe('Historia cen', () => { test('rubryki pokazuja liczby takze tam, gdzie nie ma jeszcze ogloszen', async ({ page }) => { await goto(page, priceHistoryPath('Gdańsk, Wrzeszcz')); await expect(page.locator('.history-chart-title-row strong')).not.toHaveText('—'); await expect(page.locator('.history-source-note')).toContainText(/NBP i GUS|Polska Lokalnie/); // Najnizsza, najwyzsza i srednia maja sens przy jednym punkcie; zmiana w okresie wymaga dwoch, // wiec jej nie sprawdzamy - ma wtedy wlasny podpis "Za krotka historia". const values = page.locator('.history-stat-grid article strong'); await expect(values).toHaveCount(4); for (const index of [0, 1, 3]) { await expect(values.nth(index)).not.toHaveText('—'); } }); test('bez wybranej lokalizacji nie zmyslamy zadnych liczb', async ({ page }) => { await goto(page, ROUTES.priceHistory); await expect(page.locator('.history-chart-title-row strong')).toHaveText('—'); await expect(page.locator('.history-chart-empty')).toContainText('Wybierz lokalizację'); }); }); // Minimalizacja danych (art. 5 ust. 1 lit. c RODO): w ustawieniach konta zbieramy tylko to, // czego uzywa jakas funkcja serwisu. Data urodzenia zniknela calkowicie, adres zostal firmom. test.describe('Zakres danych w ustawieniach konta', () => { test('konto prywatne nie ma daty urodzenia ani adresu zamieszkania', async ({ page }) => { await login(page); await goto(page, ROUTES.accountSettings); await expect(page.getByText('Data urodzenia')).toHaveCount(0); await expect(page.getByText('Adres zamieszkania')).toHaveCount(0); await expect(page.getByText('Adres siedziby')).toHaveCount(0); // Pola, ktore maja zastosowanie, zostaja na miejscu. await expect(page.getByText('Numer telefonu').first()).toBeVisible(); }); test('backend nie zapisze adresu przyslanego z konta prywatnego', async ({ page }) => { await login(page); const token = await page.evaluate(() => window.localStorage.getItem('polskalokalnie-auth-token')); expect(token).toBeTruthy(); const me = await page.request.get('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } }).then((r) => r.json()); expect(me.accountType).toBe('PERSONAL'); const updated = await page.request.put('/api/auth/me', { headers: { Authorization: `Bearer ${token}` }, data: { fullName: me.fullName, phone: me.phone ?? undefined, address: 'ul. Podrzucona 1, Warszawa', contactPreference: me.contactPreference, preferredLanguage: me.preferredLanguage, }, }).then((r) => r.json()); expect(updated.address).toBeNull(); // Data urodzenia nie jest juz czescia profilu w API. expect(updated.birthDate).toBeUndefined(); }); }); // Przelaczniki powiadomien maja realnie sterowac lista na /powiadomienia - nie byc ozdoba. test.describe('Ustawienia powiadomien', () => { test('w karcie nie ma martwych przelacznikow', async ({ page }) => { await login(page); await goto(page, ROUTES.accountSettings); const card = page.locator('section[aria-label="Powiadomienia"]'); await expect(card).toBeVisible(); await expect(card).not.toContainText('Wkrótce'); // Kazdy przelacznik w karcie da sie klikac. const switches = card.getByRole('switch'); await expect(switches).toHaveCount(5); for (let index = 0; index < 5; index += 1) { await expect(switches.nth(index)).toBeEnabled(); } // Naglowek prowadzi na liste powiadomien. await card.getByRole('link', { name: 'listę powiadomień' }).click(); await expect(page).toHaveURL(new RegExp(`${ROUTES.notifications}$`)); }); test('wylaczenie zapisanych wyszukiwan przezywa odswiezenie', async ({ page }) => { await login(page); await goto(page, ROUTES.accountSettings); const row = page.locator('.toggle-row').filter({ hasText: 'Nowe oferty z zapisanych wyszukiwań' }); const toggle = row.getByRole('switch'); const wasOn = await toggle.getAttribute('aria-checked'); await toggle.click(); await page.waitForTimeout(700); await page.reload(); await page.waitForTimeout(1200); await expect(page.locator('.toggle-row').filter({ hasText: 'Nowe oferty z zapisanych wyszukiwań' }).getByRole('switch')) .toHaveAttribute('aria-checked', wasOn === 'true' ? 'false' : 'true'); // Sprzatanie: wracamy do stanu sprzed testu. await page.locator('.toggle-row').filter({ hasText: 'Nowe oferty z zapisanych wyszukiwań' }).getByRole('switch').click(); await page.waitForTimeout(700); }); }); // Pomoc i kontakt: kanaly kontaktu wypelniaja szerokosc panelu, a FAQ jest podzielone // na moduly serwisu i pokazuje schematyczne rysunki ekranow. test.describe('Pomoc i kontakt', () => { test('kanaly kontaktu zajmuja cala szerokosc panelu', async ({ page }) => { await login(page); await goto(page, ROUTES.accountHelpContact); const grid = await page.locator('.help-contact-channel-grid').boundingBox(); const cards = await page.locator('.help-contact-channel-card').all(); expect(cards.length).toBeGreaterThan(0); const last = await cards[cards.length - 1].boundingBox(); // Ostatnia karta konczy sie na prawej krawedzi siatki - zadnej pustej kolumny. expect(grid!.x + grid!.width - (last!.x + last!.width)).toBeLessThanOrEqual(2); }); test('FAQ ma grupy modulow i rysunek w odpowiedzi', async ({ page }) => { await login(page); await goto(page, ROUTES.accountHelpContact); const groups = page.locator('.help-faq-group'); await expect(groups).toHaveCount(4); await expect(page.locator('.help-contact-faq-list details').first()).toBeVisible(); const item = page.locator('.help-contact-faq-list details').filter({ hasText: 'Jak dodać ogłoszenie?' }).first(); await expect(item.locator('.help-figure')).toBeHidden(); await item.locator('summary').click(); await expect(item.locator('.help-figure svg')).toBeVisible(); await expect(item.locator('figcaption')).not.toBeEmpty(); }); }); // Kafelki wyboru nie moga sciskac sie do kilkudziesieciu pikseli - wtedy podpisy lamaly sie // po literach ("Og rze wa nie po dlo go we"). Kolumna ma minimalna szerokosc, reszta idzie nizej. test.describe('Kafelki wyboru w kreatorze wyceny', () => { for (const width of [1920, 1600, 1440, 1366, 1280, 1100]) { test(`podpisy udogodnien mieszcza sie przy szerokosci ${width}`, async ({ page }) => { await page.setViewportSize({ width, height: 1000 }); await goto(page, ROUTES.valuationCondition); const labels = page.locator('.valuation-amenity-grid button span'); await expect(labels.first()).toBeVisible(); const measured = await labels.evaluateAll((nodes) => nodes.map((node) => { const rect = node.getBoundingClientRect(); return { text: node.textContent ?? '', width: rect.width, height: rect.height }; })); for (const item of measured) { // Ponizej ~90 px nawet krotkie slowo zaczyna sie lamac w pionie. expect(item.width, `podpis "${item.text}"`).toBeGreaterThan(90); // Najdluzsza nazwa to dwa slowa - wiecej niz dwa wiersze znacza lamanie po literach. expect(item.height, `podpis "${item.text}"`).toBeLessThanOrEqual(34); } }); } }); // Ten sam problem co w kreatorze wyceny dotyczyl kafelkow mediow i udogodnien w formularzu // ogloszenia. Test przechodzi kreator do kroku "Dodatkowe informacje" i mierzy podpisy. test.describe('Kafelki wyboru w formularzu ogloszenia', () => { test('podpisy mediow i udogodnien lamia sie miedzy slowami', async ({ page }) => { // Formularz edycji wchodzi z kompletem danych, wiec kroki przepuszczaja walidacje. // Konto z gotowymi ogloszeniami: to samo, ktorego uzywaja testy ulubionych i wyszukiwan. await goto(page, ROUTES.login); await page.getByPlaceholder('Wpisz swój adres e-mail').fill('anna.kowalska@example.com'); await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password); await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click(); await page.waitForURL(/\/konto$/); const token = await page.evaluate(() => window.localStorage.getItem('polskalokalnie-auth-token')); const own = await page.request.get('/api/listings/mine', { headers: { Authorization: `Bearer ${token}` } }) .then((response) => response.json()); expect(own.length, 'konto testowe musi mieć własne ogłoszenie').toBeGreaterThan(0); await page.setViewportSize({ width: 1440, height: 1200 }); await goto(page, `/edytuj-ogloszenie/${own[0].id}`); // Baner zgody stoi nad paskiem akcji na dole kroku - bez zamkniecia przejmuje klikniecie. const consent = page.getByRole('button', { name: 'Tylko niezbędne' }); if (await consent.count() > 0) { await consent.click(); } // Formularz edycji dociaga dane ogloszenia - dopoki pola sa puste, walidacja nie puszcza dalej. await expect(page.locator('.add-steps-item.active strong')).toContainText('Szczegóły oferty'); await page.waitForTimeout(1500); // Trzy kroki dalej: Szczegoly -> Lokalizacja -> Zdjecia -> Dodatkowe informacje. for (const next of ['Lokalizacja', 'Zdjęcia', 'Dodatkowe informacje']) { await page.getByRole('button', { name: new RegExp('^Dalej') }).first().click(); await expect(page.locator('.add-steps-item.active strong')).toContainText(next); } await expect(page.locator('.add-steps-item.active strong')).toContainText('Dodatkowe informacje'); const labels = page.locator('.extra-option-grid label span'); await expect(labels.first()).toBeVisible(); const measured = await labels.evaluateAll((nodes) => nodes.map((node) => ({ text: node.textContent ?? '', height: node.getBoundingClientRect().height, }))); expect(measured.length).toBeGreaterThan(5); for (const item of measured) { // Najdluzsze podpisy to trzy slowa - powyzej dwoch wierszy znaczy lamanie po literach. expect(item.height, `podpis "${item.text}"`).toBeLessThanOrEqual(34); } }); }); // Kalkulator zdolnosci: bufor zalezy od rodzaju oprocentowania (Rekomendacja S), a prog wyzszego // DStI i podpowiadane oprocentowanie pochodza ze wskaznikow pobieranych z GUS i Eurostatu. test.describe('Kalkulator zdolnosci kredytowej', () => { test('wskazniki rynkowe sa dostepne publicznie', async ({ page }) => { const indicators = await page.request.get('/api/market-indicators').then((response) => response.json()); const keys = indicators.map((item: { key: string }) => item.key); expect(keys).toContain('average-wage-gross'); expect(keys).toContain('money-market-3m'); for (const item of indicators) { expect(item.value, `wskaznik ${item.key}`).toBeGreaterThan(0); expect(item.asOf, `wskaznik ${item.key}`).toBeTruthy(); expect(item.source, `wskaznik ${item.key}`).toBeTruthy(); } }); test('zmienne oprocentowanie daje nizsza zdolnosc niz stale', async ({ page }) => { await goto(page, ROUTES.creditCalculator); await page.locator('.credit-field').filter({ hasText: 'Twój dochód' }).locator('input').fill('9000'); await page.waitForTimeout(400); const maxLoan = async () => { const row = page.locator('.credit-details article, .credit-detail-row, .credit-detail') .filter({ hasText: 'Maksymalna kwota kredytu' }).first(); const text = await row.innerText(); return Number(text.replace(/[^\d]/g, '')); }; await page.getByRole('button', { name: 'Stałe', exact: true }).click(); await page.waitForTimeout(400); const withFixed = await maxLoan(); await page.getByRole('button', { name: 'Zmienne', exact: true }).click(); await page.waitForTimeout(400); const withVariable = await maxLoan(); expect(withFixed).toBeGreaterThan(0); // Wyzszy bufor przy zmiennej stopie musi obnizyc maksymalna kwote kredytu. expect(withVariable).toBeLessThan(withFixed); }); test('podpowiadane oprocentowanie i prog DStI podaja zrodlo', async ({ page }) => { await goto(page, ROUTES.creditCalculator); await page.waitForTimeout(800); const rate = await page.locator('.credit-field').filter({ hasText: 'Oprocentowanie' }).locator('input').inputValue(); expect(Number(rate.replace(',', '.'))).toBeGreaterThan(0); const notes = (await page.locator('.credit-method-note').allInnerTexts()).join(' '); expect(notes).toContain('GUS'); expect(notes).toContain('Eurostat'); await expect(page.locator('.credit-details, .credit-detail-row, .credit-detail').first()).toContainText('próg'); }); });