Alerty cenowe na serwerze - powiadomienia tylko w serwisie
Deploy Polska Lokalnie / deploy (push) Successful in 22s

Alerty liczyla dotad przegladarka, wiec dzialaly wylacznie przy otwartej
stronie, a rynek porownywaly malymi literami ("wtorny" vs "Wtórny"),
przez co alert lokalizacji z wybranym rynkiem nigdy nie mial danych.

Teraz: tabele price_alerts i price_alert_events, /api/price-alerts oraz
codzienny przeglad o 03:00 (po nocnym zapisie srednich cen). Ogloszenie -
powiadomienie przy kazdej zmianie ceny; lokalizacja - przy zmianie
sredniej o co najmniej 2%. Wynik trafia wylacznie na liste powiadomien
uzytkownika; alerty nie wysylaja zadnych e-maili.

Alerty zalozone w przegladarce przenosimy raz przy pierwszym wejsciu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 18:00:40 +02:00
parent 008223f311
commit a4457df5f8
11 changed files with 1040 additions and 535 deletions
@@ -0,0 +1,177 @@
package pl.polskalokalnie.pricealert;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import pl.polskalokalnie.listing.PropertyType;
/**
* Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
* Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
* otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
*
* <p>Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.</p>
*/
@Entity
@Table(name = "price_alerts")
public class PriceAlert {
public enum Kind {
/** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
LISTING,
/** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
LOCATION
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Kind kind;
/** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
@Column(nullable = false, length = 200)
private String label;
// --- alert ogloszenia ---
@Column(name = "listing_id")
private Long listingId;
// --- alert lokalizacji ---
@Column(length = 120)
private String city;
/** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
@Column(length = 60)
private String region;
@Enumerated(EnumType.STRING)
@Column(name = "property_type", length = 20)
private PropertyType propertyType;
/** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
@Column(length = 20)
private String market;
/** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
@Column(name = "last_value")
private Long lastValue;
@Column(name = "last_checked_at")
private Instant lastCheckedAt;
@Column(name = "notifications_enabled", nullable = false)
private boolean notificationsEnabled = true;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
public Long getId() {
return id;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public Kind getKind() {
return kind;
}
public void setKind(Kind kind) {
this.kind = kind;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public PropertyType getPropertyType() {
return propertyType;
}
public void setPropertyType(PropertyType propertyType) {
this.propertyType = propertyType;
}
public String getMarket() {
return market;
}
public void setMarket(String market) {
this.market = market;
}
public Long getLastValue() {
return lastValue;
}
public void setLastValue(Long lastValue) {
this.lastValue = lastValue;
}
public Instant getLastCheckedAt() {
return lastCheckedAt;
}
public void setLastCheckedAt(Instant lastCheckedAt) {
this.lastCheckedAt = lastCheckedAt;
}
public boolean isNotificationsEnabled() {
return notificationsEnabled;
}
public void setNotificationsEnabled(boolean notificationsEnabled) {
this.notificationsEnabled = notificationsEnabled;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,45 @@
package pl.polskalokalnie.pricealert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
* zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
* jedno powiadomienie na alert dziennie, bez zasypywania listy.
*/
@Component
public class PriceAlertChecker {
private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
private final PriceAlertService priceAlertService;
public PriceAlertChecker(PriceAlertService priceAlertService) {
this.priceAlertService = priceAlertService;
}
@Scheduled(cron = "0 0 3 * * *")
public void checkDaily() {
run();
}
/** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
@Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
public void checkOnStartup() {
run();
}
private void run() {
try {
int notified = priceAlertService.checkAll();
if (notified > 0) {
log.info("Alerty cenowe: wyslano {} powiadomien", notified);
}
} catch (RuntimeException ex) {
log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
}
}
}
@@ -0,0 +1,60 @@
package pl.polskalokalnie.pricealert;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
@RestController
@RequestMapping("/api/price-alerts")
public class PriceAlertController {
private final PriceAlertService priceAlertService;
public PriceAlertController(PriceAlertService priceAlertService) {
this.priceAlertService = priceAlertService;
}
@GetMapping
public List<PriceAlertResponse> list(Authentication authentication) {
return priceAlertService.list(authentication.getName());
}
/** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
@GetMapping("/events")
public List<PriceAlertEventResponse> events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
return priceAlertService.events(authentication.getName(), days);
}
@PostMapping
public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
return priceAlertService.create(authentication.getName(), request);
}
public record NotificationsRequest(boolean enabled) {
}
@PutMapping("/{id}/notifications")
public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
Authentication authentication) {
return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
priceAlertService.delete(authentication.getName(), id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,30 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
public final class PriceAlertDtos {
private PriceAlertDtos() {
}
/**
* Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
* ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
*/
public record PriceAlertRequest(Long listingId, String label, String city, String region,
String propertyType, String market) {
}
/**
* kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
* a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
*/
public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
String propertyType, String market, Long lastValue, Instant lastCheckedAt,
boolean notificationsEnabled, Instant createdAt) {
}
public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
long previousValue, long currentValue, Instant changedAt) {
}
}
@@ -0,0 +1,113 @@
package pl.polskalokalnie.pricealert;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
* ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
*/
@Entity
@Table(name = "price_alert_events")
public class PriceAlertEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "alert_id", nullable = false)
private Long alertId;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PriceAlert.Kind kind;
@Column(nullable = false, length = 200)
private String label;
@Column(name = "listing_id")
private Long listingId;
@Column(name = "previous_value", nullable = false)
private long previousValue;
@Column(name = "current_value", nullable = false)
private long currentValue;
@Column(name = "changed_at", nullable = false)
private Instant changedAt = Instant.now();
public Long getId() {
return id;
}
public Long getAlertId() {
return alertId;
}
public void setAlertId(Long alertId) {
this.alertId = alertId;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public PriceAlert.Kind getKind() {
return kind;
}
public void setKind(PriceAlert.Kind kind) {
this.kind = kind;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public long getPreviousValue() {
return previousValue;
}
public void setPreviousValue(long previousValue) {
this.previousValue = previousValue;
}
public long getCurrentValue() {
return currentValue;
}
public void setCurrentValue(long currentValue) {
this.currentValue = currentValue;
}
public Instant getChangedAt() {
return changedAt;
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceAlertEventRepository extends JpaRepository<PriceAlertEvent, Long> {
List<PriceAlertEvent> findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
void deleteByAlertId(Long alertId);
}
@@ -0,0 +1,14 @@
package pl.polskalokalnie.pricealert;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceAlertRepository extends JpaRepository<PriceAlert, Long> {
List<PriceAlert> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
Optional<PriceAlert> findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
List<PriceAlert> findByKind(PriceAlert.Kind kind);
}
@@ -0,0 +1,248 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
import pl.polskalokalnie.pricehistory.PriceSnapshotService;
/**
* Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach.
*
* <p>Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
* uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
* ruchu pocztowego.</p>
*/
@Service
public class PriceAlertService {
private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
/**
* Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
* progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
* zmiana jest dla obserwujacego istotna.
*/
static final double LOCATION_MIN_CHANGE_PCT = 2.0;
private final PriceAlertRepository alertRepository;
private final PriceAlertEventRepository eventRepository;
private final ListingRepository listingRepository;
private final PriceSnapshotService snapshotService;
private final NotificationService notificationService;
public PriceAlertService(PriceAlertRepository alertRepository,
PriceAlertEventRepository eventRepository,
ListingRepository listingRepository,
PriceSnapshotService snapshotService,
NotificationService notificationService) {
this.alertRepository = alertRepository;
this.eventRepository = eventRepository;
this.listingRepository = listingRepository;
this.snapshotService = snapshotService;
this.notificationService = notificationService;
}
@Transactional(readOnly = true)
public List<PriceAlertResponse> list(String userEmail) {
return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
.map(PriceAlertService::toResponse)
.toList();
}
@Transactional(readOnly = true)
public List<PriceAlertEventResponse> events(String userEmail, int days) {
Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
.map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
event.getChangedAt()))
.toList();
}
@Transactional
public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
if (request == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
}
PriceAlert alert = request.listingId() != null
? buildListingAlert(userEmail, request.listingId())
: buildLocationAlert(userEmail, request);
alert.setLastValue(measure(alert));
alert.setLastCheckedAt(Instant.now());
return toResponse(alertRepository.save(alert));
}
@Transactional
public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
PriceAlert alert = requireOwn(userEmail, id);
alert.setNotificationsEnabled(enabled);
return toResponse(alert);
}
@Transactional
public void delete(String userEmail, Long id) {
PriceAlert alert = requireOwn(userEmail, id);
eventRepository.deleteByAlertId(alert.getId());
alertRepository.delete(alert);
}
/**
* Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
* powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
*/
@Transactional
public int checkAll() {
int notified = 0;
for (PriceAlert alert : alertRepository.findAll()) {
try {
if (checkOne(alert)) {
notified++;
}
} catch (RuntimeException ex) {
log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
}
}
return notified;
}
private boolean checkOne(PriceAlert alert) {
Long current = measure(alert);
if (current == null) {
return false;
}
Long previous = alert.getLastValue();
alert.setLastValue(current);
alert.setLastCheckedAt(Instant.now());
if (previous == null || previous <= 0 || previous.equals(current)) {
return false;
}
double changePct = ((current - previous) * 100.0) / previous;
if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
return false;
}
PriceAlertEvent event = new PriceAlertEvent();
event.setAlertId(alert.getId());
event.setUserEmail(alert.getUserEmail());
event.setKind(alert.getKind());
event.setLabel(alert.getLabel());
event.setListingId(alert.getListingId());
event.setPreviousValue(previous);
event.setCurrentValue(current);
eventRepository.save(event);
if (alert.isNotificationsEnabled()) {
notify(alert, previous, current, changePct);
}
return true;
}
private void notify(PriceAlert alert, long previous, long current, double changePct) {
boolean down = current < previous;
String direction = down ? "spadła" : "wzrosła";
String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
? "listing:" + alert.getListingId()
: "priceAlerts";
String title = alert.getKind() == PriceAlert.Kind.LISTING
? "Cena " + direction + ": " + alert.getLabel()
: "Zmiana cen: " + alert.getLabel();
String body = alert.getKind() == PriceAlert.Kind.LISTING
? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
: String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
// dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
title, body, link, dedupeKey);
}
/** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
private Long measure(PriceAlert alert) {
if (alert.getKind() == PriceAlert.Kind.LISTING) {
return listingRepository.findById(alert.getListingId())
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(PropertyListing::getPrice)
.map(price -> price.longValue())
.orElse(null);
}
PriceSnapshotService.Average average = snapshotService.currentAverage(
OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
alert.getCity(), "");
return average == null ? null : average.avgPricePerM2();
}
private PriceAlert buildListingAlert(String userEmail, Long listingId) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
if (existing != null) {
return existing;
}
PriceAlert alert = new PriceAlert();
alert.setUserEmail(userEmail);
alert.setKind(PriceAlert.Kind.LISTING);
alert.setListingId(listingId);
alert.setLabel(listing.getTitle());
return alert;
}
private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
if (request.city() == null || request.city().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
}
String city = request.city().trim();
PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
String market = request.market() == null ? "" : request.market().trim();
PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
.filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
.filter(item -> city.equalsIgnoreCase(item.getCity()))
.filter(item -> item.getPropertyType() == propertyType)
.filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
.findFirst()
.orElseGet(PriceAlert::new);
alert.setUserEmail(userEmail);
alert.setKind(PriceAlert.Kind.LOCATION);
alert.setCity(city);
alert.setRegion(request.region() == null ? null : request.region().trim());
alert.setPropertyType(propertyType);
alert.setMarket(market);
alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
return alert;
}
private PriceAlert requireOwn(String userEmail, Long id) {
return alertRepository.findById(id)
.filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
}
private static String money(long value) {
return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
}
private static PriceAlertResponse toResponse(PriceAlert alert) {
return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
alert.getCity(), alert.getRegion(),
alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
alert.isNotificationsEnabled(), alert.getCreatedAt());
}
}
@@ -0,0 +1,30 @@
package pl.polskalokalnie.pricealert;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Prog dla alertow lokalizacji. Cena pojedynczego ogloszenia progu nie ma - tam kazda zmiana
* jest istotna dla obserwujacego, wiec sprawdzamy wylacznie regule dla sredniej rynkowej.
*/
class PriceAlertThresholdTest {
private boolean notifiesLocation(long previous, long current) {
double changePct = ((current - previous) * 100.0) / previous;
return Math.abs(changePct) >= PriceAlertService.LOCATION_MIN_CHANGE_PCT;
}
@Test
void ignoresSmallMoveOfTheAverage() {
// 14 000 -> 14 200 to +1,4%: ponizej progu, bo srednia rusza sie przy kazdym nowym ogloszeniu.
assertThat(notifiesLocation(14_000, 14_200)).isFalse();
assertThat(notifiesLocation(14_000, 13_800)).isFalse();
}
@Test
void notifiesAboutRealMove() {
assertThat(notifiesLocation(14_000, 14_280)).isTrue();
assertThat(notifiesLocation(14_659, 13_523)).isTrue();
}
}
+269 -528
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -620,3 +620,38 @@ test.describe('Historia cen', () => {
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/);
});
});