Compare commits

..

4 Commits

Author SHA1 Message Date
Grendgi
cb8e290d8f test: cover propertyfinder matching rules
Some checks failed
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 35s
CI / go (push) Successful in 46s
CI / python (push) Failing after 2s
2026-06-17 17:12:49 +03:00
Grendgi
f73c9fba5f feat: expose monitoring pf health detail 2026-06-17 15:59:53 +03:00
Grendgi
ccd56165c7 chore: use common internal auth 2026-06-17 14:26:04 +03:00
Grendgi
ea2063ff40 chore: use common header parsing 2026-06-17 14:19:13 +03:00
8 changed files with 372 additions and 31 deletions

View File

@@ -32,4 +32,6 @@ jobs:
needs: hygiene needs: hygiene
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: python3 -m pip install -r requirements.txt
- run: python3 -m compileall app - run: python3 -m compileall app
- run: python3 -m unittest discover -s tests

View File

@@ -10,6 +10,8 @@ import json
import sys import sys
from typing import Any from typing import Any
from sqlalchemy import text
from app.db import SessionLocal, init_db from app.db import SessionLocal, init_db
from app.models import Project from app.models import Project
from app.services.monitor import ( from app.services.monitor import (
@@ -131,7 +133,17 @@ def cmd_suggest(payload: dict[str, Any]) -> None:
db.close() db.close()
def cmd_health(_: dict[str, Any]) -> None:
db = SessionLocal()
try:
db.execute(text("SELECT 1"))
_write({"status": "ok"})
finally:
db.close()
COMMANDS = { COMMANDS = {
"health": cmd_health,
"add-listing": cmd_add_listing, "add-listing": cmd_add_listing,
"add-listings": cmd_add_listings, "add-listings": cmd_add_listings,
"check-project": cmd_check_project, "check-project": cmd_check_project,

7
go.mod
View File

@@ -1,8 +1,11 @@
module monitoring-pf module monitoring-pf
go 1.25.0 go 1.25.7
require modernc.org/sqlite v1.50.1 require (
gitea.estateliga.work/admin/portal-common v0.3.0
modernc.org/sqlite v1.50.1
)
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect

2
go.sum
View File

@@ -1,3 +1,5 @@
gitea.estateliga.work/admin/portal-common v0.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM=
gitea.estateliga.work/admin/portal-common v0.3.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=

View File

@@ -1,12 +1,16 @@
package pf package pf
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
) )
type Server struct { type Server struct {
@@ -21,17 +25,32 @@ type bulkPayload struct {
URLs []string `json:"urls"` URLs []string `json:"urls"`
} }
type componentProbe struct {
Name string `json:"name"`
Status string `json:"status"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := s.apiPath(r.URL.Path) path := s.apiPath(r.URL.Path)
switch { switch {
case path == "/healthz": case path == "/healthz":
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
case path == "/health/detail":
s.healthDetail(w, r)
case path == "/": case path == "/":
writeJSON(w, http.StatusOK, map[string]string{"service": "monitoring-pf", "ui": "portal", "api": "go"}) writeJSON(w, http.StatusOK, map[string]string{"service": "monitoring-pf", "ui": "portal", "api": "go"})
case !strings.HasPrefix(path, "/api/v1"): case !strings.HasPrefix(path, "/api/v1"):
writeError(w, http.StatusNotFound, "not found") writeError(w, http.StatusNotFound, "not found")
case !s.checkInternalAuth(w, r): default:
return commonmw.InternalAuth(s.App.Cfg.InternalAPIKey)(http.HandlerFunc(s.serveAPI)).ServeHTTP(w, r)
}
}
func (s Server) serveAPI(w http.ResponseWriter, r *http.Request) {
path := s.apiPath(r.URL.Path)
switch {
case path == "/api/v1/access/me" && r.Method == http.MethodGet: case path == "/api/v1/access/me" && r.Method == http.MethodGet:
s.accessMe(w, r) s.accessMe(w, r)
case path == "/api/v1/summary" && r.Method == http.MethodGet: case path == "/api/v1/summary" && r.Method == http.MethodGet:
@@ -53,18 +72,6 @@ func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
func (s Server) checkInternalAuth(w http.ResponseWriter, r *http.Request) bool {
want := strings.TrimSpace(s.App.Cfg.InternalAPIKey)
if want == "" {
return true
}
if r.Header.Get("X-Internal-Key") != want {
writeError(w, http.StatusUnauthorized, "unauthorized")
return false
}
return true
}
func (s Server) apiPath(path string) string { func (s Server) apiPath(path string) string {
base := s.App.Cfg.PublicBasePath base := s.App.Cfg.PublicBasePath
if base != "" && path == base { if base != "" && path == base {
@@ -76,6 +83,104 @@ func (s Server) apiPath(path string) string {
return path return path
} }
func (s Server) healthDetail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
components := []componentProbe{
s.probeDatabase(ctx),
s.probeWorker(ctx),
s.probeTelegram(ctx),
s.probeScheduler(ctx),
s.probeProjectIntegrity(ctx),
}
writeJSON(w, http.StatusOK, map[string]any{"components": components})
}
func (s Server) probeDatabase(ctx context.Context) componentProbe {
start := time.Now()
if err := s.App.DB.PingContext(ctx); err != nil {
return componentProbe{Name: "sqlite", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "sqlite", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeWorker(ctx context.Context) componentProbe {
start := time.Now()
if err := s.App.Worker.Health(ctx); err != nil {
return componentProbe{Name: "python_worker", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "python_worker", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeTelegram(ctx context.Context) componentProbe {
start := time.Now()
if !s.App.TG.Enabled() {
return componentProbe{Name: "telegram_bot", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: "TG_BOT_TOKEN is not configured"}
}
if _, err := s.App.TG.BotUsername(ctx); err != nil {
return componentProbe{Name: "telegram_bot", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "telegram_bot", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeScheduler(ctx context.Context) componentProbe {
start := time.Now()
staleAfter := max(1, s.App.Cfg.ScrapeIntervalHours*2) * 3600
staleModifier := "-" + strconv.Itoa(staleAfter) + " seconds"
var total, neverChecked, stale int64
err := s.App.DB.QueryRowContext(ctx, `
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN last_checked_at IS NULL THEN 1 ELSE 0 END), 0) AS never_checked,
COALESCE(SUM(CASE WHEN last_checked_at IS NOT NULL AND datetime(last_checked_at) < datetime('now', ?) THEN 1 ELSE 0 END), 0) AS stale
FROM projects`,
staleModifier,
).Scan(&total, &neverChecked, &stale)
if err != nil {
return componentProbe{Name: "scheduler", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if total > 0 && (neverChecked > 0 || stale > 0) {
return componentProbe{
Name: "scheduler",
Status: "down",
LatencyMs: time.Since(start).Milliseconds(),
Error: "projects=" + strconv.FormatInt(total, 10) +
" never_checked=" + strconv.FormatInt(neverChecked, 10) +
" stale=" + strconv.FormatInt(stale, 10) +
" stale_after_sec=" + strconv.Itoa(staleAfter),
}
}
return componentProbe{Name: "scheduler", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeProjectIntegrity(ctx context.Context) componentProbe {
start := time.Now()
var projects, missingOwner, autoWithoutPermit, searchURLs int64
err := s.App.DB.QueryRowContext(ctx, `
SELECT
(SELECT COUNT(*) FROM projects),
(SELECT COUNT(*) FROM projects p LEFT JOIN employees e ON e.id = p.owner_id WHERE e.id IS NULL),
(SELECT COUNT(*) FROM competitor_listings WHERE auto_discovered = 1 AND (permit_number IS NULL OR trim(permit_number) = '')),
(SELECT COUNT(*) FROM competitor_listings WHERE url LIKE '%/search%' OR url LIKE '%?%' AND external_id = '')
`).Scan(&projects, &missingOwner, &autoWithoutPermit, &searchURLs)
if err != nil {
return componentProbe{Name: "project_integrity", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if missingOwner > 0 || autoWithoutPermit > 0 || searchURLs > 0 {
return componentProbe{
Name: "project_integrity",
Status: "down",
LatencyMs: time.Since(start).Milliseconds(),
Error: "projects=" + strconv.FormatInt(projects, 10) +
" missing_owner=" + strconv.FormatInt(missingOwner, 10) +
" auto_without_permit=" + strconv.FormatInt(autoWithoutPermit, 10) +
" suspicious_search_urls=" + strconv.FormatInt(searchURLs, 10),
}
}
return componentProbe{Name: "project_integrity", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) accessMe(w http.ResponseWriter, r *http.Request) { func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
portalID := portalUserID(r) portalID := portalUserID(r)
emp, err := s.App.CurrentEmployee(r.Context(), portalID, false) emp, err := s.App.CurrentEmployee(r.Context(), portalID, false)
@@ -510,11 +615,11 @@ func portalUserID(r *http.Request) string {
} }
func isAdmin(r *http.Request) bool { func isAdmin(r *http.Request) bool {
return r.Header.Get("X-User-Is-Admin") == "1" return commonmw.HeaderBool(r, "X-User-Is-Admin")
} }
func canViewTeam(r *http.Request) bool { func canViewTeam(r *http.Request) bool {
return isAdmin(r) || r.Header.Get("X-User-Is-Department-Head") == "1" return isAdmin(r) || commonmw.HeaderBool(r, "X-User-Is-Department-Head")
} }
func canManagePortalUser(r *http.Request, targetPortalID string) bool { func canManagePortalUser(r *http.Request, targetPortalID string) bool {
@@ -534,19 +639,7 @@ func canManagePortalUser(r *http.Request, targetPortalID string) bool {
} }
func subordinatePortalIDs(r *http.Request) []string { func subordinatePortalIDs(r *http.Request) []string {
raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates")) return commonmw.HeaderCSV(r, "X-User-Subordinates")
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
id := strings.TrimSpace(part)
if id != "" {
out = append(out, id)
}
}
return out
} }
func ownerPortalIDFromQuery(r *http.Request) *string { func ownerPortalIDFromQuery(r *http.Request) *string {

67
internal/pf/store_test.go Normal file
View File

@@ -0,0 +1,67 @@
package pf
import (
"strings"
"testing"
)
func strPtr(v string) *string {
return &v
}
func int64Ptr(v int64) *int64 {
return &v
}
func float64Ptr(v float64) *float64 {
return &v
}
func validProjectPayload() ProjectPayload {
return ProjectPayload{
Title: "Full Park View",
DealType: "sale",
OurPrice: float64Ptr(2500000),
DLDPermit: strPtr("7140504127"),
Building: strPtr("Harbour Gate Tower 2"),
Bedrooms: int64Ptr(2),
SizeSqft: float64Ptr(1081),
OurURL: strPtr(
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html",
),
}
}
func TestValidateProjectRequiredAcceptsConcretePropertyFinderListingURL(t *testing.T) {
payload := validProjectPayload()
if err := validateProjectRequired(payload); err != nil {
t.Fatalf("validateProjectRequired() returned unexpected error: %v", err)
}
}
func TestValidateProjectRequiredRejectsSearchPageAsOurURL(t *testing.T) {
payload := validProjectPayload()
payload.OurURL = strPtr("https://www.propertyfinder.ae/en/search?c=1&l=12345")
err := validateProjectRequired(payload)
if err == nil {
t.Fatal("validateProjectRequired() accepted a search page as our_url")
}
if !strings.Contains(err.Error(), "concrete PropertyFinder listing URL") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateProjectRequiredRejectsListingLikeURLWithoutID(t *testing.T) {
payload := validProjectPayload()
payload.OurURL = strPtr("https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour.html")
err := validateProjectRequired(payload)
if err == nil {
t.Fatal("validateProjectRequired() accepted a listing-like URL without listing id")
}
if !strings.Contains(err.Error(), "concrete PropertyFinder listing URL") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -34,6 +34,10 @@ type CheckResult struct {
Changes int `json:"changes"` Changes int `json:"changes"`
} }
type HealthResult struct {
Status string `json:"status"`
}
type Suggestion struct { type Suggestion struct {
Source string `json:"source"` Source string `json:"source"`
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
@@ -104,6 +108,17 @@ func (w *Worker) Suggest(ctx context.Context, projectID int64) (*SuggestionsResp
return &out, nil return &out, nil
} }
func (w *Worker) Health(ctx context.Context) error {
var out HealthResult
if err := w.call(ctx, "health", map[string]any{}, &out); err != nil {
return err
}
if out.Status != "ok" {
return fmt.Errorf("worker status=%s", out.Status)
}
return nil
}
func (w *Worker) call(ctx context.Context, command string, payload any, out any) error { func (w *Worker) call(ctx context.Context, command string, payload any, out any) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel() defer cancel()

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import Base
from app.models import CompetitorListing, DealType, Employee, ListingStatus, Project, Source
from app.scrapers.base import ScrapedListing
from app.scrapers.propertyfinder import PropertyFinderScraper
from app.services import monitor
PF_OWN_URL = (
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-"
"the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html"
)
PF_COMPETITOR_URL = (
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-"
"the-lagoons-harbour-gate-harbour-gate-tower-2-86170000.html"
)
def _listing(external_id: str, permit: str | None, url: str = PF_COMPETITOR_URL) -> ScrapedListing:
return ScrapedListing(
source="propertyfinder",
external_id=external_id,
url=url,
title=f"Listing {external_id}",
price=2_500_000,
currency="AED",
permit_number=permit,
agent_name="Agent",
agency_name="Agency",
is_active=True,
)
class MonitoringRulesTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db = self.Session()
owner = Employee(name="Agent", portal_user_id="agent-1", tg_chat_id="100")
self.db.add(owner)
self.db.flush()
self.project = Project(
title="Full Park View",
deal_type=DealType.SALE,
our_price=2_500_000,
dld_permit="7140504127",
building="Harbour Gate Tower 2",
bedrooms=2,
size_sqft=1081,
our_url=PF_OWN_URL,
owner_id=owner.id,
)
self.db.add(self.project)
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_propertyfinder_rejects_search_pages(self) -> None:
scraper = PropertyFinderScraper()
self.assertFalse(scraper.is_listing_url("https://www.propertyfinder.ae/en/search?c=1&l=12345"))
self.assertIsNone(scraper.fetch_listing("https://www.propertyfinder.ae/en/search?c=1&l=12345"))
@patch.object(monitor.PF, "get_permit", side_effect=["7140504127"])
@patch.object(
monitor.PF,
"search_similar",
return_value=[
_listing("86176216", None, url=PF_OWN_URL),
_listing("86170000", None, url=PF_COMPETITOR_URL),
],
)
def test_suggest_similar_excludes_own_listing(self, _search, _permit) -> None:
suggestions = monitor.suggest_similar(self.project, our_permit="7140504127")
self.assertEqual(["86170000"], [item.external_id for item in suggestions["propertyfinder"]])
@patch.object(
monitor,
"suggest_similar",
return_value={
"propertyfinder": [
_listing("86170000", "7140504127"),
_listing("86170001", "DIFFERENT"),
],
"bayut": [],
},
)
def test_sync_permit_competitors_adds_only_exact_permit_matches(self, _suggest) -> None:
changes, suggestions, permit = monitor.sync_permit_competitors(self.db, self.project)
listings = self.db.query(CompetitorListing).order_by(CompetitorListing.external_id).all()
self.assertEqual("7140504127", permit)
self.assertEqual(1, len(listings))
self.assertEqual("86170000", listings[0].external_id)
self.assertTrue(listings[0].auto_discovered)
self.assertEqual(["86170001"], [item.external_id for item in suggestions["propertyfinder"]])
self.assertEqual(1, len(changes))
@patch.object(monitor, "suggest_similar", return_value={"propertyfinder": [], "bayut": []})
def test_auto_permit_listing_is_removed_only_after_three_misses(self, _suggest) -> None:
listing = CompetitorListing(
project_id=self.project.id,
source=Source.PROPERTYFINDER,
external_id="86170000",
url=PF_COMPETITOR_URL,
title="Competitor",
permit_number="7140504127",
auto_discovered=True,
permit_missing_checks=0,
current_price=2_500_000,
currency="AED",
status=ListingStatus.ACTIVE,
)
self.db.add(listing)
self.db.commit()
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
self.db.flush()
self.assertEqual([], changes)
self.assertEqual(1, self.db.query(CompetitorListing).count())
self.assertEqual(1, listing.permit_missing_checks)
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
self.db.flush()
self.assertEqual([], changes)
self.assertEqual(1, self.db.query(CompetitorListing).count())
self.assertEqual(2, listing.permit_missing_checks)
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
self.db.flush()
self.assertEqual(1, len(changes))
self.assertEqual(0, self.db.query(CompetitorListing).count())
if __name__ == "__main__":
unittest.main()