وبلاگ و سئو: پیشنویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + OG/canonical/JSON-LD و متای سمت سرور + تغییر رمز و محدودسازی ورود + تستها
- مقالهها ستون status (draft/published) گرفتند — GET /api/site و همه انتهای عمومی فقط منتشرشده را برمیگردانند؛ autosave دیگر پیشنویس نیمهکاره را زنده نمیکند - کلمه کلیدی هدف چکلیست سئو فیلد seo_keyphrase شد (قبلاً localStorage بود) - robots.txt و sitemap.xml از بکاند + سرو /blog و /projects با تزریق title/description/OG/JSON-LD از دیتابیس برای خزندههای بدون JS (app/seo_pages.py) - فرانت: applySeo در seo.ts (OG/توییتر/canonical/JSON-LD)، ادغام پیشنویس برای ادمین، برچسب پیشنویس در وبلاگ، حالت خالی وبلاگ، دکمه انتشار در صفحه نوشتن - تغییر رمز ادمین POST /api/auth/change-password + دیالوگ «رمز» در نوار مدیریت - محدودسازی ورود: ۵ تلاش ناموفق در ۱۵ دقیقه از هر IP → 429 - ساخت دکمه + برای ردیف سئوی صفحههای بدون ردیف (SingletonPill) - تستهای pytest (۱۵ مورد) + پینکردن requirements + حذف package.json سرگردان فرانت
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""پیکربندی تستها — دیتابیس SQLite فایلمحور و دادههای حداقلی.
|
||||
|
||||
متغیرهای محیطی پیش از ایمپورت app تنظیم میشوند تا settings همانها را بخواند.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key"
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1"
|
||||
os.environ["SITE_URL"] = "https://test.example"
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import models
|
||||
from app.auth import hash_password
|
||||
from app.config import settings
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.main import app
|
||||
|
||||
TEST_DB = pathlib.Path("test.db")
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def seed_db(tmp_path_factory):
|
||||
# اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود
|
||||
engine.dispose()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# dist ساختگی فرانتاند برای تست تزریق متا در صفحات جزئیات
|
||||
dist = tmp_path_factory.mktemp("dist")
|
||||
(dist / "index.html").write_text(
|
||||
"<!doctype html><html><head><title>Shell</title></head>"
|
||||
"<body><div id=\"root\"></div></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings.frontend_dist = str(dist)
|
||||
settings.site_url = "https://test.example"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1")))
|
||||
db.add(models.Brand(name="گروه ساختمانی آرامش"))
|
||||
db.add(
|
||||
models.Article(
|
||||
slug="published-post", title="مقاله منتشرشده", status="published",
|
||||
excerpt="چکیده مقاله", body=DOC, position=1,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
models.Article(
|
||||
slug="draft-post", title="پیشنویس", status="draft",
|
||||
excerpt="چکیده پیشنویس", body=DOC, position=2,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
db.close()
|
||||
engine.dispose()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limiter():
|
||||
from app import rate_limit
|
||||
|
||||
rate_limit._failed.clear()
|
||||
yield
|
||||
rate_limit._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_client(client):
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
|
||||
assert res.status_code == 200, res.text
|
||||
token = res.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
client.headers.pop("Authorization", None)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
# ---------- عمومی ----------
|
||||
|
||||
|
||||
def test_health(client):
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_site_hides_draft_articles(client):
|
||||
res = client.get("/api/site")
|
||||
assert res.status_code == 200
|
||||
slugs = [a["slug"] for a in res.json()["articles"]]
|
||||
assert "published-post" in slugs
|
||||
assert "draft-post" not in slugs
|
||||
|
||||
|
||||
def test_public_articles_hide_drafts(client):
|
||||
res = client.get("/api/articles")
|
||||
assert all(a["status"] == "published" for a in res.json())
|
||||
assert client.get("/api/articles/draft-post").status_code == 404
|
||||
|
||||
|
||||
def test_admin_endpoints_reject_anonymous(client):
|
||||
assert client.get("/api/admin/articles").status_code == 401
|
||||
|
||||
|
||||
# ---------- احراز هویت ----------
|
||||
|
||||
|
||||
def test_login_success_and_failure(client):
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
|
||||
== 401
|
||||
)
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["access_token"]
|
||||
|
||||
|
||||
def test_change_password(auth_client, client):
|
||||
res = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
res = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# رمز قدیمی رد و رمز جدید قبول میشود
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
# بازگرداندن رمز برای تستهای بعدی
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
|
||||
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||
res = client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
|
||||
headers=auth,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def test_login_rate_limited(client):
|
||||
for _ in range(5):
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 429
|
||||
assert "Retry-After" in res.headers
|
||||
|
||||
|
||||
# ---------- CRUD مدیریت ----------
|
||||
|
||||
|
||||
def test_article_crud_and_draft_flow(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article = created.json()
|
||||
# مقاله تازه پیشنویس است و در سایت عمومی دیده نمیشود
|
||||
assert article["status"] == "draft"
|
||||
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
|
||||
assert res.json()["seo_keyphrase"] == "ساختمان"
|
||||
|
||||
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
|
||||
assert res.status_code == 200
|
||||
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
|
||||
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
|
||||
def test_service_roundtrip_and_reorder(auth_client):
|
||||
ids = []
|
||||
for name in ("خدمت الف", "خدمت ب"):
|
||||
res = auth_client.post("/api/admin/services", json={"title": name})
|
||||
assert res.status_code == 201
|
||||
ids.append(res.json()["id"])
|
||||
|
||||
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
|
||||
assert res.status_code == 200
|
||||
ordered = [s["id"] for s in res.json() if s["id"] in ids]
|
||||
assert ordered == ids[::-1]
|
||||
|
||||
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
|
||||
assert res.json()["desc"] == "توضیح تازه"
|
||||
|
||||
for i in ids:
|
||||
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
|
||||
|
||||
|
||||
def test_seo_meta_create_and_patch(auth_client):
|
||||
res = auth_client.post(
|
||||
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
|
||||
)
|
||||
assert res.status_code == 201
|
||||
meta_id = res.json()["id"]
|
||||
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
|
||||
assert res.json()["title"] == "عنوان تازه"
|
||||
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
|
||||
|
||||
|
||||
# ---------- سئو ----------
|
||||
|
||||
|
||||
def test_robots_txt(client):
|
||||
res = client.get("/robots.txt")
|
||||
assert res.status_code == 200
|
||||
assert "Disallow: /admin" in res.text
|
||||
assert "Sitemap: https://test.example/sitemap.xml" in res.text
|
||||
|
||||
|
||||
def test_sitemap_includes_published_only(client):
|
||||
res = client.get("/sitemap.xml")
|
||||
assert res.status_code == 200
|
||||
assert "/blog/published-post" in res.text
|
||||
assert "/blog/draft-post" not in res.text
|
||||
assert res.text.startswith("<?xml")
|
||||
|
||||
|
||||
def test_head_injection_for_article(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "مقاله منتشرشده" in res.text
|
||||
assert 'property="og:title"' in res.text
|
||||
assert "application/ld+json" in res.text
|
||||
assert "https://test.example/blog/published-post" in res.text
|
||||
|
||||
|
||||
def test_head_not_injected_for_draft(client):
|
||||
res = client.get("/blog/draft-post")
|
||||
assert res.status_code == 200
|
||||
assert "پیشنویس" not in res.text # عنوان پیشنویس افشا نمیشود
|
||||
assert "application/ld+json" not in res.text
|
||||
|
||||
|
||||
def test_head_replaces_title_once(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert len(re.findall(r"<title>", res.text)) == 1
|
||||
Reference in New Issue
Block a user