Test-2 commit

This commit is contained in:
TronoSfera 2026-02-23 22:13:46 +03:00
parent 331973e283
commit 7b6fd8c7c2
8 changed files with 51 additions and 16 deletions

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import re import re
import uuid import uuid
from functools import lru_cache from functools import lru_cache
from urllib.parse import quote from urllib.parse import quote, urlsplit
import boto3 import boto3
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
@ -46,17 +46,32 @@ class S3Storage:
kwargs: dict = {"Bucket": self.bucket} kwargs: dict = {"Bucket": self.bucket}
if settings.S3_REGION and settings.S3_REGION != "us-east-1": if settings.S3_REGION and settings.S3_REGION != "us-east-1":
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": settings.S3_REGION} kwargs["CreateBucketConfiguration"] = {"LocationConstraint": settings.S3_REGION}
try:
self.client.create_bucket(**kwargs) self.client.create_bucket(**kwargs)
except ClientError as create_exc:
create_code = str(create_exc.response.get("Error", {}).get("Code", ""))
if create_code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}:
raise
self._bucket_checked = True self._bucket_checked = True
@staticmethod
def _proxy_presigned_url(raw_url: str) -> str:
# Route pre-signed requests through frontend `/s3/*` proxy to avoid browser cross-origin issues.
parts = urlsplit(str(raw_url or ""))
if not parts.path:
return raw_url
query = ("?" + parts.query) if parts.query else ""
return "/s3" + parts.path + query
def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str: def create_presigned_put_url(self, key: str, mime_type: str, expires_sec: int = 900) -> str:
self.ensure_bucket() self.ensure_bucket()
return self.client.generate_presigned_url( url = self.client.generate_presigned_url(
"put_object", "put_object",
Params={"Bucket": self.bucket, "Key": key, "ContentType": mime_type}, Params={"Bucket": self.bucket, "Key": key, "ContentType": mime_type},
ExpiresIn=expires_sec, ExpiresIn=expires_sec,
HttpMethod="PUT", HttpMethod="PUT",
) )
return self._proxy_presigned_url(url)
def head_object(self, key: str) -> dict: def head_object(self, key: str) -> dict:
self.ensure_bucket() self.ensure_bucket()

View file

@ -8,9 +8,8 @@
</head> </head>
<body> <body>
<div id="admin-root"></div> <div id="admin-root"></div>
<script crossorigin="anonymous" integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z" src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script crossorigin="anonymous" integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1" src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" integrity="sha384-Fo0OdKhdnE7y2WmzjOMW4PYjHkkANeu1501pWTqKrzAPeJMFQb4ZTdAA9dtrVUJV" src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <script src="/admin.js"></script>
<script type="text/babel" data-presets="env,react" src="/admin.jsx"></script>
</body> </body>
</html> </html>

View file

@ -1661,7 +1661,6 @@
...prev, ...prev,
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
})); }));
setTableCatalog([]);
if (roleOverride !== "ADMIN") return; if (roleOverride !== "ADMIN") return;
@ -1831,7 +1830,10 @@
(tableKey, form, mode) => { (tableKey, form, mode) => {
const fields = getRecordFields(tableKey); const fields = getRecordFields(tableKey);
const payload = {}; const payload = {};
const isLawyerRequestEdit = tableKey === "requests" && role === "LAWYER";
const lawyerRequestRestricted = new Set(["assigned_lawyer_id", "effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"]);
fields.forEach((field) => { fields.forEach((field) => {
if (isLawyerRequestEdit && lawyerRequestRestricted.has(field.key)) return;
const raw = form[field.key]; const raw = form[field.key];
if (field.type === "boolean") { if (field.type === "boolean") {
payload[field.key] = raw === "true"; payload[field.key] = raw === "true";
@ -1880,7 +1882,7 @@
if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number; if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number;
return payload; return payload;
}, },
[getRecordFields] [getRecordFields, role]
); );
const submitRecordModal = useCallback( const submitRecordModal = useCallback(
@ -2378,12 +2380,12 @@
let cancelled = false; let cancelled = false;
(async () => { (async () => {
await bootstrapReferenceData(token, role); await bootstrapReferenceData(token, role);
if (!cancelled) await refreshSection(activeSection, token); if (!cancelled) await loadDashboard(token);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [bootstrapReferenceData, refreshSection, role, token]); }, [bootstrapReferenceData, loadDashboard, role, token]);
useEffect(() => { useEffect(() => {
if (!dictionaryTableItems.length) { if (!dictionaryTableItems.length) {

Binary file not shown.

View file

@ -22,9 +22,9 @@ docker compose exec -T backend python -m compileall app tests alembic
docker compose build frontend docker compose build frontend
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js" docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
``` ```
5. Браузерный E2E (Playwright) для публичного флоу: 5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN):
```bash ```bash
docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft.com/playwright:v1.51.0-noble sh -lc "npm install && E2E_BASE_URL=http://frontend npx playwright test --config=playwright.config.js" docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft.com/playwright:v1.58.2-jammy sh -lc "npm install --silent && E2E_BASE_URL=http://frontend E2E_ADMIN_EMAIL=admin@example.com E2E_ADMIN_PASSWORD='AdminPass-123!' E2E_LAWYER_EMAIL=ivan@mail.ru E2E_LAWYER_PASSWORD='LawyerPass-123!' npx playwright test --config=playwright.config.js"
``` ```
## Матрица проверок по задачам ## Матрица проверок по задачам
@ -60,7 +60,7 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN) ## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
### PUBLIC (клиент) ### PUBLIC (клиент)
- Лендинг и клиентский контур (ручной e2e через `http://localhost:8081`): открыть лендинг, создать заявку, открыть кабинет. - Лендинг и клиентский контур через UI e2e: `e2e/tests/public_client_flow.spec.js` (создание заявки, кабинет, чат, загрузка файла).
- OTP create/view + 7-day cookie + rate-limit: `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py`. - OTP create/view + 7-day cookie + rate-limit: `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py`.
- Просмотр статуса/истории/чата/файлов/таймлайна по `track_number`: `tests/test_public_cabinet.py`. - Просмотр статуса/истории/чата/файлов/таймлайна по `track_number`: `tests/test_public_cabinet.py`.
- Переписка клиент -> юрист и маркеры непрочитанного: `tests/test_public_cabinet.py`, `tests/test_notifications.py`. - Переписка клиент -> юрист и маркеры непрочитанного: `tests/test_public_cabinet.py`, `tests/test_notifications.py`.
@ -68,6 +68,7 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
- Публичные счета и PDF в кабинете: `tests/test_invoices.py`. - Публичные счета и PDF в кабинете: `tests/test_invoices.py`.
### LAWYER (юрист) ### LAWYER (юрист)
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, чтение обновлений, смена статуса).
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`. - Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`. - Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`. - Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
@ -77,6 +78,7 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
- Счета: видимость только своих, запрет ставить `PAID`: `tests/test_invoices.py`, `tests/test_billing_flow.py`. - Счета: видимость только своих, запрет ставить `PAID`: `tests/test_invoices.py`, `tests/test_billing_flow.py`.
### ADMIN (администратор) ### ADMIN (администратор)
- UI e2e: `e2e/tests/admin_role_flow.spec.js` (вход, справочники, создание пользователя/темы, создание и оплата счета).
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`. - CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`.
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`. - Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`. - Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`.
@ -94,4 +96,4 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
## Последний регрессионный прогон ## Последний регрессионный прогон
- `python -m unittest discover -s tests -p 'test_*.py' -v``94 tests OK`. - `python -m unittest discover -s tests -p 'test_*.py' -v``94 tests OK`.
- `Playwright public flow` (`e2e/tests/public_client_flow.spec.js`) — `1 passed`. - `Playwright UI roles` (`e2e/tests/admin_role_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`, `e2e/tests/public_client_flow.spec.js`) — `3 passed`.

View file

@ -7,7 +7,7 @@
"test": "playwright test --config=playwright.config.js" "test": "playwright test --config=playwright.config.js"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.51.0", "@playwright/test": "1.58.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2"
} }

View file

@ -1,4 +1,12 @@
FROM node:22-alpine AS admin-build
WORKDIR /build
COPY app/web/admin.jsx ./admin.jsx
RUN npm init -y >/dev/null 2>&1 \
&& npm install --silent esbuild@0.25.10 \
&& npx esbuild admin.jsx --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY app/web/ /usr/share/nginx/html/ COPY app/web/ /usr/share/nginx/html/
COPY --from=admin-build /build/admin.js /usr/share/nginx/html/admin.js
RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html

View file

@ -40,6 +40,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /s3/ {
proxy_pass http://minio:9000/;
proxy_http_version 1.1;
proxy_set_header Host minio:9000;
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;
}
location /health { location /health {
proxy_pass http://backend:8000/health; proxy_pass http://backend:8000/health;
proxy_http_version 1.1; proxy_http_version 1.1;