Compare commits

..

8 Commits

Author SHA1 Message Date
RM
2fd62fba75 add monitoring network
All checks were successful
CD / build-and-push (push) Successful in 9s
CD / deploy (push) Successful in 7s
2026-10-15 12:17:33 +03:00
RM
76aa1ab253 added metrics for prometheus
All checks were successful
CD / build-and-push (push) Successful in 3m48s
CD / deploy (push) Successful in 10s
2026-10-15 11:54:45 +03:00
RM
7438e2b57a add missed file
All checks were successful
CD / build-and-push (push) Successful in 6s
CD / deploy (push) Successful in 15s
2026-10-15 02:02:05 +03:00
RM
7ad5361a61 change cd for front
Some checks failed
CD / build-and-push (push) Failing after 10s
CD / deploy (push) Has been skipped
2026-10-15 02:01:10 +03:00
RM
e9204516cf change delivery step
All checks were successful
CD / build-and-push (push) Successful in 9s
CD / deploy (push) Successful in 13s
2026-10-15 01:30:59 +03:00
RM
a4067202ec add missed files
Some checks failed
CD / build-and-push (push) Successful in 4m11s
CD / deploy (push) Failing after 2s
2026-10-15 00:58:44 +03:00
RM
5e77a63aff add dummy front 2026-10-15 00:29:42 +03:00
RM
97d6d9adc6 2 stage build 2026-10-15 00:29:21 +03:00
28 changed files with 2557 additions and 21 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
target/
.git/
.idea/
*.iml
frontend/node_modules/
frontend/dist/
*.log
.env

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Email settings
EMAIL_FROM=m.ryabyh@gmail.com
EMAIL_TOKEN=your_app_password_here
EMAIL_TO=m.ryabyh@gmail.com,AAAtapina@sberbank.ru,Oreshkina.V.V@sberbank.ru,MEvRyabykh@sberbank.ru
EMAIL_DAYS=1
SPECIAL_EMAIL=m.ryabyh+torgi@gmail.com
ENABLE_EMAIL_SCAN=false
# Database settings
DB_URL=jdbc:postgresql://localhost:5432/torgi
DB_USERNAME=torgi
DB_PASSWORD=your_db_password_here

48
.gitea/workflows/cd.yaml Normal file
View File

@@ -0,0 +1,48 @@
name: CD
on:
push:
branches: [master]
workflow_dispatch:
env:
REGISTRY: gitea.rmevs.vip
BACKEND_IMAGE: gitea.rmevs.vip/rm/torgi2-backend
FRONTEND_IMAGE: gitea.rmevs.vip/rm/torgi2-frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push backend
run: docker build -t "$BACKEND_IMAGE:latest" -f Dockerfile . && docker push "$BACKEND_IMAGE:latest"
- name: Build and push frontend
run: docker build -t "$FRONTEND_IMAGE:latest" ./frontend && docker push "$FRONTEND_IMAGE:latest"
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to prod over SSH
env:
SSH_KEY: ${{ secrets.SSH_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
SSH_USER: ${{ secrets.SSH_USER }}
DEPLOY_DIR: ${{ secrets.SSH_DEPLOY_DIR }}
COMPOSE_FILE: ${{ secrets.COMPOSE_FILE }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
PORT="${SSH_PORT:-22}"
FILE="${COMPOSE_FILE:-docker-compose.yml}"
ssh -i ~/.ssh/deploy_key -p "$PORT" -o StrictHostKeyChecking=no -o BatchMode=yes "$SSH_USER@$SSH_HOST" \
"cd $DEPLOY_DIR && git pull --ff-only && docker compose -f \"$FILE\" pull && sudo -n systemctl stop torgi && sudo -n systemctl start torgi && sudo -n systemctl is-active torgi"

View File

@@ -1,3 +1,15 @@
FROM eclipse-temurin:26-jre-alpine # Этап 1: сборка jar внутри Docker (maven package отдельно запускать не нужно)
COPY torgi-0.0.1-SNAPSHOT.jar /home/torgi-0.0.1-SNAPSHOT.jar FROM maven:3.9-eclipse-temurin-17 AS build
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75", "-XX:InitialRAMPercentage=50", "-Xms512m", "-Xmx4096m", "-jar", "/home/torgi-0.0.1-SNAPSHOT.jar"] WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN chmod +x mvnw && ./mvnw -B dependency:go-offline
COPY src ./src
RUN ./mvnw -B package -DskipTests
# Этап 2: запуск (JRE 17 — та же версия, что java.version в pom.xml)
FROM eclipse-temurin:17-jre-alpine
WORKDIR /home
COPY --from=build /app/target/torgi-0.0.1-SNAPSHOT.jar /home/torgi-0.0.1-SNAPSHOT.jar
EXPOSE 8181
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75", "-XX:InitialRAMPercentage=50", "-Xms512m", "-Xmx4096m", "-jar", "/home/torgi-0.0.1-SNAPSHOT.jar"]

View File

@@ -1,7 +1,7 @@
version: '3.8' version: '3.8'
services: services:
client-backend: client-backend:
image: client:0.0.1 image: gitea.rmevs.vip/rm/torgi2-backend:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -14,6 +14,9 @@ services:
- SPRING_CLOUD_CONFIG_URI=http://192.168.2.22:10022 - SPRING_CLOUD_CONFIG_URI=http://192.168.2.22:10022
- SPRING_CLOUD_CONFIG_PROFILE=prod - SPRING_CLOUD_CONFIG_PROFILE=prod
restart: always restart: always
networks:
- default
- monitoring
healthcheck: healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8181/health || exit 1 test: wget --no-verbose --tries=1 --spider http://localhost:8181/health || exit 1
interval: 20s interval: 20s
@@ -44,7 +47,20 @@ services:
- "5050:80" - "5050:80"
volumes: volumes:
- pgadmin-data:/var/lib/pgadmin - pgadmin-data:/var/lib/pgadmin
profiles:
- tools
volumes: frontend:
db-data: image: gitea.rmevs.vip/rm/torgi2-frontend:latest
pgadmin-data: build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- client-backend
restart: always
networks:
monitoring:
external: true

2
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Этап 1: сборка React-приложения.
# VITE_API_URL не задаем: фронт ходит на относительный /api,
# который этот же nginx проксирует в бэкенд (см. nginx.conf).
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . ./
RUN npm run build
# Этап 2: раздача статики + прокси API.
# Хост бэкенда задается переменной BACKEND_HOST (шаблон обрабатывается
# entrypoint'ом nginx при старте контейнера).
FROM nginx:1.27-alpine
ENV BACKEND_HOST=client-backend
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 80

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Торги — события</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,30 @@
server {
listen 80;
# API бэкенда. Хост задается переменной BACKEND_HOST:
# в нашем docker-compose.yaml это client-backend (дефолт в Dockerfile),
# в кастомных файлах переопределите через environment.
location /api/ {
proxy_pass http://${BACKEND_HOST}:8181;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Swagger UI бэкенда (удобно на первое время, потом можно закрыть)
location /swagger-ui/ {
proxy_pass http://${BACKEND_HOST}:8181;
proxy_set_header Host $host;
}
location /v3/api-docs {
proxy_pass http://${BACKEND_HOST}:8181;
proxy_set_header Host $host;
}
# Статика React SPA
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}

1792
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "torgi-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.4.8"
}
}

10
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,10 @@
import EventsPage from './pages/EventsPage';
export default function App() {
return (
<main className="container">
<h1>Торги — события</h1>
<EventsPage />
</main>
);
}

24
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Page, TorgiEvent } from './types';
// В dev-режиме запросы идут через vite-proxy (см. vite.config.ts),
// поэтому BASE по умолчанию пустой. Для продакшна задать VITE_API_URL,
// например https://torgi.example.com
const BASE = import.meta.env.VITE_API_URL ?? '';
export interface EventFilter {
cadastral?: string;
status?: string;
page: number;
size: number;
}
export async function fetchEvents(filter: EventFilter): Promise<Page<TorgiEvent>> {
const q = new URLSearchParams();
if (filter.cadastral) q.set('cadastral', filter.cadastral);
if (filter.status) q.set('status', filter.status);
q.set('page', String(filter.page));
q.set('size', String(filter.size));
const res = await fetch(`${BASE}/api/events?${q.toString()}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return (await res.json()) as Page<TorgiEvent>;
}

54
frontend/src/index.css Normal file
View File

@@ -0,0 +1,54 @@
body {
font-family: system-ui, sans-serif;
margin: 0;
background: #f6f6f6;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
input, button {
padding: 6px 10px;
font-size: 14px;
}
table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
th, td {
border: 1px solid #ddd;
padding: 6px 8px;
text-align: left;
font-size: 14px;
}
th {
background: #eee;
}
.pagination {
display: flex;
gap: 12px;
align-items: center;
margin-top: 12px;
}
.total {
color: #555;
}
.error {
color: #b00020;
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchEvents } from '../api';
import type { Page, TorgiEvent } from '../types';
const PAGE_SIZE = 20;
function cell(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '—';
return String(value);
}
export default function EventsPage() {
const [cadastral, setCadastral] = useState('');
const [status, setStatus] = useState('');
const [applied, setApplied] = useState({ cadastral: '', status: '' });
const [page, setPage] = useState(0);
const [data, setData] = useState<Page<TorgiEvent> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchEvents({ ...applied, page, size: PAGE_SIZE });
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [applied, page]);
useEffect(() => {
void load();
}, [load]);
const onSearch = () => {
setPage(0);
setApplied({ cadastral: cadastral.trim(), status: status.trim() });
};
return (
<section>
<div className="filters">
<input
placeholder="Кадастровый номер"
value={cadastral}
onChange={(e) => setCadastral(e.target.value)}
/>
<input
placeholder="Статус"
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
<button onClick={onSearch} disabled={loading}>
Найти
</button>
</div>
{loading && <p>Загрузка…</p>}
{error && <p className="error">Ошибка: {error}</p>}
{data && (
<>
<p className="total">Всего: {data.totalElements}</p>
<table>
<thead>
<tr>
<th>Извещение</th>
<th>Лот</th>
<th>Кадастр</th>
<th>Статус</th>
<th>Нач. цена</th>
<th>Итог. цена</th>
<th>Дата торгов</th>
</tr>
</thead>
<tbody>
{data.content.map((ev) => (
<tr key={ev.id}>
<td>{cell(ev.notice)}</td>
<td>{cell(ev.lotNumber)}</td>
<td>{cell(ev.cadastral)}</td>
<td>{cell(ev.status)}</td>
<td>{cell(ev.startPrice)}</td>
<td>{cell(ev.finalPrice)}</td>
<td>{cell(ev.tradesDate)}</td>
</tr>
))}
{data.content.length === 0 && (
<tr>
<td colSpan={7}>Ничего не найдено</td>
</tr>
)}
</tbody>
</table>
<div className="pagination">
<button disabled={page === 0 || loading} onClick={() => setPage((p) => p - 1)}>
← Назад
</button>
<span>
Стр. {data.number + 1} из {Math.max(data.totalPages, 1)}
</span>
<button
disabled={data.number + 1 >= data.totalPages || loading}
onClick={() => setPage((p) => p + 1)}
>
Вперед →
</button>
</div>
</>
)}
</section>
);
}

26
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface TorgiEvent {
id: number;
key: string | null;
status: string | null;
bidStart: string | null;
bidEnd: string | null;
tradesDate: string | null;
startPrice: string | null;
step: string | null;
initMoney: string | null;
finalPrice: string | null;
notice: string | null;
lotNumber: number | null;
cadastral: string | null;
type: string | null;
fullInfo: string | null;
createdAt: string | null;
}
export interface Page<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Dev-proxy на бэкенд, чтобы не зависеть от CORS при локальной разработке.
// VITE_API_URL имеет приоритет, если задан (например, для продакшн-сборки).
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL ?? 'http://localhost:8080',
changeOrigin: true,
},
},
},
});

View File

@@ -83,6 +83,15 @@
<groupId>org.springframework.retry</groupId> <groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId> <artifactId>spring-retry</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.17</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>

View File

@@ -0,0 +1,18 @@
package com.pledge.torgi.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(false)
.maxAge(3600);
}
}

View File

@@ -8,6 +8,8 @@ import com.pledge.torgi.service.DownloadHeadlessService;
import com.pledge.torgi.service.Lot; import com.pledge.torgi.service.Lot;
import com.pledge.torgi.service.SendEmailService; import com.pledge.torgi.service.SendEmailService;
import com.pledge.torgi.utils.StartParams; import com.pledge.torgi.utils.StartParams;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -17,8 +19,10 @@ import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -34,12 +38,21 @@ public class DownloadHeadlessController {
private final TorgiEventRepository eventRepository; private final TorgiEventRepository eventRepository;
private final MissedDataRepository missedDataRepository; private final MissedDataRepository missedDataRepository;
private final DownloadHeadlessService downloadHeadlessService; private final DownloadHeadlessService downloadHeadlessService;
private final Counter diffAdded;
private final Counter missedAdded;
private final AtomicLong lastPollTimestamp;
public DownloadHeadlessController(TorgiEventRepository eventRepository, MissedDataRepository missedDataRepository, public DownloadHeadlessController(TorgiEventRepository eventRepository, MissedDataRepository missedDataRepository,
DownloadHeadlessService downloadHeadlessService) { DownloadHeadlessService downloadHeadlessService, MeterRegistry meterRegistry) {
this.eventRepository = eventRepository; this.eventRepository = eventRepository;
this.missedDataRepository = missedDataRepository; this.missedDataRepository = missedDataRepository;
this.downloadHeadlessService = downloadHeadlessService; this.downloadHeadlessService = downloadHeadlessService;
this.diffAdded = Counter.builder("torgi.events.added").tag("source", "diff")
.description("Torgi events added from scheduled diff").register(meterRegistry);
this.missedAdded = Counter.builder("torgi.events.added").tag("source", "missed")
.description("Torgi events added from missed-data queue").register(meterRegistry);
this.lastPollTimestamp = new AtomicLong(0);
meterRegistry.gauge("torgi.last.poll.timestamp", lastPollTimestamp);
eventRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, 10000)).forEach(event -> content.add(event.getKey())); eventRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, 10000)).forEach(event -> content.add(event.getKey()));
} }
@@ -57,9 +70,11 @@ public class DownloadHeadlessController {
newEvents.add(new TorgiEvent(key, currentContent.get(key))); newEvents.add(new TorgiEvent(key, currentContent.get(key)));
content.add(key); content.add(key);
businessLogger.info("added new diff content"); businessLogger.info("added new diff content");
diffAdded.increment();
} }
} }
eventRepository.saveAll(newEvents); eventRepository.saveAll(newEvents);
lastPollTimestamp.set(Instant.now().getEpochSecond());
businessLogger.info("completeProcessing readed from torgi = {}, added to DB = {}", currentContent.size(), newEvents.size()); businessLogger.info("completeProcessing readed from torgi = {}, added to DB = {}", currentContent.size(), newEvents.size());
} }
@@ -156,6 +171,7 @@ public class DownloadHeadlessController {
newEvents.add(new TorgiEvent(key, currentContent.get(key))); newEvents.add(new TorgiEvent(key, currentContent.get(key)));
content.add(key); content.add(key);
businessLogger.info("added new missed content"); businessLogger.info("added new missed content");
missedAdded.increment();
} }
} }
eventRepository.saveAll(newEvents); eventRepository.saveAll(newEvents);

View File

@@ -0,0 +1,48 @@
package com.pledge.torgi.controller.api;
import com.pledge.torgi.model.TorgiEvent;
import com.pledge.torgi.repositories.TorgiEventRepository;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/events")
public class EventController {
private final TorgiEventRepository repository;
public EventController(TorgiEventRepository repository) {
this.repository = repository;
}
@GetMapping
public Page<TorgiEvent> list(@RequestParam(required = false) String cadastral,
@RequestParam(required = false) String status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
TorgiEvent probe = new TorgiEvent();
probe.setCadastral(cadastral);
probe.setStatus(status);
ExampleMatcher matcher = ExampleMatcher.matchingAll()
.withIgnoreNullValues()
.withIgnoreCase()
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING);
return repository.findAll(Example.of(probe, matcher), pageable);
}
@GetMapping("/{id}")
public TorgiEvent get(@PathVariable Long id) {
return repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Event not found: " + id));
}
}

View File

@@ -0,0 +1,27 @@
package com.pledge.torgi.controller.api;
import com.pledge.torgi.model.MissedData;
import com.pledge.torgi.repositories.MissedDataRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/missed")
public class MissedDataController {
private final MissedDataRepository repository;
public MissedDataController(MissedDataRepository repository) {
this.repository = repository;
}
@GetMapping
public Page<MissedData> list(@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) {
return repository.findAll(pageable);
}
}

View File

@@ -8,6 +8,7 @@ import com.pledge.torgi.service.DownloadHeadlessService;
import com.pledge.torgi.service.Lot; import com.pledge.torgi.service.Lot;
import com.pledge.torgi.service.SendEmailService; import com.pledge.torgi.service.SendEmailService;
import com.pledge.torgi.utils.StartParams; import com.pledge.torgi.utils.StartParams;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -76,7 +77,7 @@ class DownloadHeadlessControllerTest {
.thenReturn(page); .thenReturn(page);
// when // when
new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// then // then
verify(eventRepository).findAllByOrderByCreatedAtDesc(any(PageRequest.class)); verify(eventRepository).findAllByOrderByCreatedAtDesc(any(PageRequest.class));
@@ -93,7 +94,7 @@ class DownloadHeadlessControllerTest {
.thenReturn(new PageImpl<>(List.of(event1, event2))); .thenReturn(new PageImpl<>(List.of(event1, event2)));
// when // when
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// then // then
assertThat(getContent(controller)).containsExactlyInAnyOrder("key-1", "key-2"); assertThat(getContent(controller)).containsExactlyInAnyOrder("key-1", "key-2");
@@ -110,7 +111,7 @@ class DownloadHeadlessControllerTest {
lot.setFullInfo("Full info"); lot.setFullInfo("Full info");
Map<String, Lot> downloaded = Map.of(EVENT_KEY, lot); Map<String, Lot> downloaded = Map.of(EVENT_KEY, lot);
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded); when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded);
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.process(); controller.process();
@@ -138,7 +139,7 @@ class DownloadHeadlessControllerTest {
.thenReturn(new PageImpl<>(List.of(existing))); .thenReturn(new PageImpl<>(List.of(existing)));
Map<String, Lot> downloaded = Map.of(EVENT_KEY, new Lot()); Map<String, Lot> downloaded = Map.of(EVENT_KEY, new Lot());
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded); when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded);
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.process(); controller.process();
@@ -159,7 +160,7 @@ class DownloadHeadlessControllerTest {
List<String> cadastral = List.of(CADASTRAL); List<String> cadastral = List.of(CADASTRAL);
try (MockedConstruction<SendEmailService> emailConstruction = mockConstruction(SendEmailService.class)) { try (MockedConstruction<SendEmailService> emailConstruction = mockConstruction(SendEmailService.class)) {
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.sendNewContent(params, cadastral, "to@example.com"); controller.sendNewContent(params, cadastral, "to@example.com");
@@ -186,7 +187,7 @@ class DownloadHeadlessControllerTest {
StartParams params = new StartParams(); StartParams params = new StartParams();
params.setFrom("from@example.com"); params.setFrom("from@example.com");
params.setToken("token"); params.setToken("token");
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
try (MockedConstruction<SendEmailService> ignored = mockConstruction(SendEmailService.class, try (MockedConstruction<SendEmailService> ignored = mockConstruction(SendEmailService.class,
(mock, context) -> doThrow(new RuntimeException("smtp failure")) (mock, context) -> doThrow(new RuntimeException("smtp failure"))
@@ -215,7 +216,7 @@ class DownloadHeadlessControllerTest {
lot.setCadastral(CADASTRAL); lot.setCadastral(CADASTRAL);
Map<String, Lot> downloaded = Map.of(EVENT_KEY, lot); Map<String, Lot> downloaded = Map.of(EVENT_KEY, lot);
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded); when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded);
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.richData(); controller.richData();
@@ -241,7 +242,7 @@ class DownloadHeadlessControllerTest {
Map<String, Lot> downloaded = Map.of(EVENT_KEY, new Lot()); Map<String, Lot> downloaded = Map.of(EVENT_KEY, new Lot());
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded); when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded);
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.richData(); controller.richData();
@@ -260,7 +261,7 @@ class DownloadHeadlessControllerTest {
missed.setCadastral(CADASTRAL); missed.setCadastral(CADASTRAL);
when(missedDataRepository.findAll(any(PageRequest.class))).thenReturn(new PageImpl<>(List.of(missed))); when(missedDataRepository.findAll(any(PageRequest.class))).thenReturn(new PageImpl<>(List.of(missed)));
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(Collections.emptyMap()); when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(Collections.emptyMap());
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.richData(); controller.richData();
@@ -281,7 +282,7 @@ class DownloadHeadlessControllerTest {
Map<String, Lot> downloaded = Map.of(EVENT_KEY, new Lot()); Map<String, Lot> downloaded = Map.of(EVENT_KEY, new Lot());
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded); when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded);
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
controller.richData(); controller.richData();
@@ -295,7 +296,7 @@ class DownloadHeadlessControllerTest {
void richData_shouldHandleExceptionGracefully() { void richData_shouldHandleExceptionGracefully() {
// given // given
when(missedDataRepository.findAll(any(PageRequest.class))).thenThrow(new RuntimeException("Database error")); when(missedDataRepository.findAll(any(PageRequest.class))).thenThrow(new RuntimeException("Database error"));
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when / then // when / then
assertThatCode(controller::richData).doesNotThrowAnyException(); assertThatCode(controller::richData).doesNotThrowAnyException();
@@ -308,7 +309,7 @@ class DownloadHeadlessControllerTest {
File deeper = new File(nested, "deeper"); File deeper = new File(nested, "deeper");
assertThat(deeper.mkdirs()).isTrue(); assertThat(deeper.mkdirs()).isTrue();
writeFile(new File(deeper, "file.txt"), "content"); writeFile(new File(deeper, "file.txt"), "content");
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
// when // when
boolean result = invokeDeleteDirectory(controller, nested); boolean result = invokeDeleteDirectory(controller, nested);
@@ -371,7 +372,7 @@ class DownloadHeadlessControllerTest {
File nested = new File(sourceDir, "nested"); File nested = new File(sourceDir, "nested");
assertThat(nested.mkdirs()).isTrue(); assertThat(nested.mkdirs()).isTrue();
writeFile(new File(nested, "data.txt"), "archive-content"); writeFile(new File(nested, "data.txt"), "archive-content");
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService); DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, new SimpleMeterRegistry());
try { try {
// when // when
@@ -388,6 +389,27 @@ class DownloadHeadlessControllerTest {
} }
} }
@Test
void process_shouldIncrementMetrics() throws Exception {
// given
Lot lot = new Lot();
lot.setStatus("PUBLISHED");
lot.setNotice("Notice1");
lot.setLotNumber(1);
lot.setCadastral(CADASTRAL);
Map<String, Lot> downloaded = Map.of(EVENT_KEY, lot);
when(downloadHeadlessService.downloadExcel(anyString())).thenReturn(downloaded);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
DownloadHeadlessController controller = new DownloadHeadlessController(eventRepository, missedDataRepository, downloadHeadlessService, registry);
// when
controller.process();
// then
assertThat(registry.get("torgi.events.added").tag("source", "diff").counter().count()).isEqualTo(1.0);
assertThat(registry.get("torgi.last.poll.timestamp").gauge().value()).isGreaterThan(0.0);
}
// Helpers // Helpers
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@@ -0,0 +1,81 @@
package com.pledge.torgi.controller.api;
import com.pledge.torgi.model.TorgiEvent;
import com.pledge.torgi.repositories.TorgiEventRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class EventControllerTest {
@Mock
private TorgiEventRepository repository;
@InjectMocks
private EventController controller;
@Test
void list_shouldPassFiltersAndPageableToRepository() {
// given
TorgiEvent event = new TorgiEvent();
event.setKey("key-1");
Page<TorgiEvent> page = new PageImpl<>(List.of(event));
when(repository.findAll(any(Example.class), any(Pageable.class))).thenReturn(page);
Pageable pageable = PageRequest.of(0, 20);
// when
Page<TorgiEvent> result = controller.list("77:01", "PUBLISHED", pageable);
// then
assertThat(result.getContent()).containsExactly(event);
ArgumentCaptor<Example<TorgiEvent>> exampleCaptor = ArgumentCaptor.forClass(Example.class);
verify(repository).findAll(exampleCaptor.capture(), eq(pageable));
assertThat(exampleCaptor.getValue().getProbe().getCadastral()).isEqualTo("77:01");
assertThat(exampleCaptor.getValue().getProbe().getStatus()).isEqualTo("PUBLISHED");
}
@Test
void get_shouldReturnEvent() {
// given
TorgiEvent event = new TorgiEvent();
event.setKey("key-1");
when(repository.findById(1L)).thenReturn(Optional.of(event));
// when
TorgiEvent result = controller.get(1L);
// then
assertThat(result).isSameAs(event);
}
@Test
void get_shouldThrow404WhenMissing() {
// given
when(repository.findById(42L)).thenReturn(Optional.empty());
// when / then
assertThatThrownBy(() -> controller.get(42L))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("404");
}
}

View File

@@ -0,0 +1,61 @@
package com.pledge.torgi.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.annotation.Retryable;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.io.UncheckedIOException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = DownloadHeadlessRetryTest.RetryConfig.class)
class DownloadHeadlessRetryTest {
@Configuration
@EnableRetry
static class RetryConfig {
@Bean
DownloadHeadlessService downloadHeadlessService() {
return new DownloadHeadlessService();
}
}
@Autowired
private DownloadHeadlessService service;
@Test
void downloadExcel_shouldBeRetryable() throws Exception {
Method method = DownloadHeadlessService.class.getMethod("downloadExcel", String.class);
Retryable retryable = method.getAnnotation(Retryable.class);
assertThat(retryable).isNotNull();
assertThat(retryable.maxAttempts()).isEqualTo(11);
assertThat(Arrays.asList(retryable.retryFor())).contains(UncheckedIOException.class);
Backoff backoff = retryable.backoff();
assertThat(backoff.delay()).isEqualTo(1000);
}
@Test
void downloadExcelFallback_shouldReturnEmptyMap() {
Map<String, Lot> result = new DownloadHeadlessService()
.downloadExcelFallback(new RuntimeException("boom"), "http://example.com/file.xlsx");
assertThat(result).isEmpty();
}
@Test
void downloadExcel_shouldRetryAndRecoverToEmptyMap() {
// connection refused -> UncheckedIOException on every attempt -> @Recover fallback
Map<String, Lot> result = service.downloadExcel("http://127.0.0.1:1/missing.xlsx");
assertThat(result).isEmpty();
}
}