Add remote browser pages and website sync

Enable managed remote browser custom pages with login autofill and add website sync workflows so external admin surfaces can be handled inside SmartUp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-15 15:43:58 +08:00
parent a13a0070a5
commit 7adc7c00ab
43 changed files with 6615 additions and 641 deletions
+353
View File
@@ -0,0 +1,353 @@
import json
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
sys.path.insert(0, str(Path(__file__).resolve().parent))
from app.database import Base, get_db
from app.main import app
from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream
from app.models.notification_log import NotificationLog
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.models.webhook_config import WebhookConfig
from app.routers import websites as websites_router
from app.utils.auth import get_current_user
@pytest.fixture()
def db_session():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = lambda: object()
try:
yield TestClient(app)
finally:
app.dependency_overrides.clear()
def seed_rows(db_session, *, website_enabled=True, auto_sync_enabled=True):
website = Website(
name="Target",
site_type="sub2api",
base_url="http://target.local",
api_prefix="/api",
auth_type="api_key",
auth_config_json="{}",
groups_endpoint="/groups",
group_update_endpoint="/groups/{id}",
enabled=website_enabled,
auto_sync_enabled=auto_sync_enabled,
)
upstream = Upstream(
name="Upstream",
base_url="http://upstream.local",
api_prefix="/api",
auth_type="bearer",
auth_config_json="{}",
)
db_session.add_all([website, upstream])
db_session.commit()
db_session.refresh(website)
db_session.refresh(upstream)
snapshot = UpstreamRateSnapshot(
upstream_id=upstream.id,
snapshot_json=json.dumps({
"groups": {
"source": {
"group_id": "source",
"group_name": "Source",
"rate": "2",
}
}
}),
)
db_session.add(snapshot)
db_session.commit()
return website, upstream
def binding_payload(website_id, upstream_id, *, enabled=True):
return {
"website_id": website_id,
"target_group_id": "target",
"target_group_name": "Target group",
"source_groups": [{
"upstream_id": upstream_id,
"upstream_name": "Upstream",
"group_id": "source",
"group_name": "Source",
}],
"percent": 10,
"algorithm": "max_plus_percent",
"enabled": enabled,
}
def test_create_binding_runs_initial_sync(monkeypatch, client, db_session):
website, upstream = seed_rows(db_session)
calls = []
class FakeClient:
def __init__(self, **kwargs):
pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "rate_multiplier": "1"}]
def update_group_rate(self, endpoint, group_id, rate):
calls.append((endpoint, group_id, str(rate)))
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient)
response = client.post("/api/group-bindings", json=binding_payload(website.id, upstream.id))
assert response.status_code == 201
assert response.json()["target_group_id"] == "target"
assert calls == [("/groups/{id}", "target", "2.2000")]
log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success"
assert log.message == "同步成功"
assert log.old_rate == "1"
assert log.new_rate == "2.2"
def test_create_binding_skips_write_when_website_auto_sync_disabled(client, db_session):
website, upstream = seed_rows(db_session, auto_sync_enabled=False)
response = client.post("/api/group-bindings", json=binding_payload(website.id, upstream.id))
assert response.status_code == 201
assert db_session.query(WebsiteGroupBinding).count() == 1
log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success"
assert log.message == "网站未启用自动同步,未写回"
assert log.old_rate is None
assert log.new_rate == "2.2"
def test_create_binding_skips_write_when_binding_disabled(client, db_session):
website, upstream = seed_rows(db_session)
response = client.post("/api/group-bindings", json=binding_payload(website.id, upstream.id, enabled=False))
assert response.status_code == 201
log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success"
assert log.message == "绑定未启用,未写回"
assert log.new_rate == "2.2"
def test_create_binding_keeps_binding_when_initial_sync_calculation_fails(client, db_session):
website, upstream = seed_rows(db_session)
db_session.query(UpstreamRateSnapshot).delete()
db_session.commit()
response = client.post("/api/group-bindings", json=binding_payload(website.id, upstream.id))
assert response.status_code == 201
assert db_session.query(WebsiteGroupBinding).count() == 1
log = db_session.query(WebsiteSyncLog).one()
assert log.status == "failed"
assert "没有可用的正数上游倍率" in log.message
assert log.new_rate is None
def test_update_binding_runs_sync_after_save(monkeypatch, client, db_session):
website, upstream = seed_rows(db_session)
binding = WebsiteGroupBinding(
website_id=website.id,
target_group_id="target",
target_group_name="Target group",
source_groups_json=json.dumps([{
"upstream_id": upstream.id,
"upstream_name": "Upstream",
"group_id": "source",
"group_name": "Source",
}], ensure_ascii=False),
percent="10",
algorithm="max_plus_percent",
enabled=True,
)
db_session.add(binding)
db_session.commit()
db_session.refresh(binding)
calls = []
class FakeClient:
def __init__(self, **kwargs):
pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "rate_multiplier": "1"}]
def update_group_rate(self, endpoint, group_id, rate):
calls.append((endpoint, group_id, str(rate)))
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient)
response = client.put(
f"/api/group-bindings/{binding.id}",
json={
"target_group_name": "Target group",
"percent": 20,
"enabled": True,
},
)
assert response.status_code == 200
assert calls == [("/groups/{id}", "target", "2.4000")]
log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success"
assert log.message == "同步成功"
assert log.new_rate == "2.4"
def test_update_binding_skips_write_when_disabled(monkeypatch, client, db_session):
website, upstream = seed_rows(db_session)
binding = WebsiteGroupBinding(
website_id=website.id,
target_group_id="target",
target_group_name="Target group",
source_groups_json=json.dumps([{
"upstream_id": upstream.id,
"upstream_name": "Upstream",
"group_id": "source",
"group_name": "Source",
}], ensure_ascii=False),
percent="10",
algorithm="max_plus_percent",
enabled=False,
)
db_session.add(binding)
db_session.commit()
db_session.refresh(binding)
class FakeClient:
def __init__(self, **kwargs):
raise AssertionError("should not write when binding is disabled")
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient)
response = client.put(
f"/api/group-bindings/{binding.id}",
json={
"target_group_name": "Target group",
"percent": 20,
},
)
assert response.status_code == 200
log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success"
assert log.message == "绑定未启用,未写回"
def test_create_binding_notifies_when_website_rate_changes(monkeypatch, client, db_session):
website, upstream = seed_rows(db_session)
webhook = WebhookConfig(
name="Notify",
type="generic",
url="http://notify.local/webhook",
enabled=True,
events_json=json.dumps(["website_rate_changed"]),
)
db_session.add(webhook)
db_session.commit()
sent_payloads = []
class FakeClient:
def __init__(self, **kwargs):
pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "rate_multiplier": "1"}]
def update_group_rate(self, endpoint, group_id, rate):
pass
def fake_send_generic(url, payload, timeout=15.0):
sent_payloads.append((url, payload))
return "ok"
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.webhook_service._send_generic", fake_send_generic)
response = client.post("/api/group-bindings", json=binding_payload(website.id, upstream.id))
assert response.status_code == 201
assert len(sent_payloads) == 1
_, payload = sent_payloads[0]
assert payload["event"] == "website_rate_changed"
assert payload["website"]["id"] == website.id
assert payload["target_group"]["old_rate"] == "1"
assert payload["target_group"]["new_rate"] == "2.2"
log = db_session.query(NotificationLog).one()
assert log.event_type == "website_rate_changed"
assert log.status == "success"
def test_create_binding_does_not_notify_when_website_rate_unchanged(monkeypatch, client, db_session):
website, upstream = seed_rows(db_session)
webhook = WebhookConfig(
name="Notify",
type="generic",
url="http://notify.local/webhook",
enabled=True,
events_json=json.dumps(["website_rate_changed"]),
)
db_session.add(webhook)
db_session.commit()
class FakeClient:
def __init__(self, **kwargs):
pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "rate_multiplier": "2.2"}]
def update_group_rate(self, endpoint, group_id, rate):
pass
def fake_send_generic(url, payload, timeout=15.0):
raise AssertionError("should not notify when target rate is unchanged")
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr("app.services.webhook_service._send_generic", fake_send_generic)
response = client.post("/api/group-bindings", json=binding_payload(website.id, upstream.id))
assert response.status_code == 201
assert db_session.query(NotificationLog).count() == 0