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');
});
});