feat: add appointments, campaigns, admin, storage, tests and various updates

- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

151
tests/test_widget.py Normal file
View File

@@ -0,0 +1,151 @@
"""
Tests for the widget.js endpoint and the JS bundle it generates.
GET /widget.js must:
- Return 200 with application/javascript content-type
- Include CORS header (any site can load it as a <script>)
- Cache-Control must be set
- Body must be valid JavaScript (basic structural checks)
- APP_URL placeholder must be replaced with the real app URL
- Must NOT expose the raw __APP_URL__ placeholder
- Must contain the public API surface (window.Contexta)
- Must contain the chatbot ID read logic (data-chatbot)
- Must be a self-executing IIFE
"""
import pytest
from unittest.mock import patch
from app.services.widget import generate_widget_js
# ── Unit tests: generate_widget_js() ──────────────────────────────────────────
class TestGenerateWidgetJs:
def test_replaces_app_url_placeholder(self):
js = generate_widget_js("https://app.example.com")
assert "https://app.example.com" in js
def test_strips_trailing_slash(self):
js = generate_widget_js("https://app.example.com/")
assert "https://app.example.com/" not in js
assert "https://app.example.com" in js
def test_no_raw_placeholder_in_output(self):
js = generate_widget_js("https://app.example.com")
assert "__APP_URL__" not in js
def test_constructs_chat_url_pattern(self):
js = generate_widget_js("https://app.example.com")
# The JS concatenates base + '/chat/' + chatbotId
assert "/chat/" in js
def test_is_iife(self):
js = generate_widget_js("https://app.example.com")
assert "(function" in js
assert "}());" in js or "})()" in js or "}())" in js or "()})" in js or "}());" in js
def test_contains_double_init_guard(self):
js = generate_widget_js("https://app.example.com")
assert "__ctxa" in js
def test_reads_data_chatbot_attribute(self):
js = generate_widget_js("https://app.example.com")
assert "data-chatbot" in js
def test_uses_document_current_script(self):
js = generate_widget_js("https://app.example.com")
assert "currentScript" in js
def test_public_api_exposed(self):
js = generate_widget_js("https://app.example.com")
assert "window.Contexta" in js
assert "open" in js
assert "close" in js
assert "toggle" in js
def test_lazy_iframe_loading(self):
"""Iframe src should only be set on first open, not at init time."""
js = generate_widget_js("https://app.example.com")
# The _loaded flag pattern ensures lazy load
assert "_loaded" in js or "frameLoaded" in js or "loaded" in js.lower()
def test_escape_key_closes_panel(self):
js = generate_widget_js("https://app.example.com")
assert "Escape" in js
def test_aria_attributes_present(self):
js = generate_widget_js("https://app.example.com")
assert "aria-label" in js
assert "aria-expanded" in js
def test_mobile_responsive_css(self):
js = generate_widget_js("https://app.example.com")
assert "480px" in js # mobile breakpoint
def test_high_z_index(self):
"""Widget must sit on top of host-page content."""
js = generate_widget_js("https://app.example.com")
# 2147483647 is the highest browser-supported z-index
assert "2147483647" in js
def test_sandbox_attribute_on_iframe(self):
js = generate_widget_js("https://app.example.com")
assert "sandbox" in js
assert "allow-scripts" in js
def test_returns_string(self):
result = generate_widget_js("https://app.example.com")
assert isinstance(result, str)
assert len(result) > 500 # must be a substantial bundle
# ── Integration tests: GET /widget.js ─────────────────────────────────────────
class TestWidgetEndpoint:
def test_returns_200(self, client):
resp = client.get("/widget.js")
assert resp.status_code == 200
def test_content_type_is_javascript(self, client):
resp = client.get("/widget.js")
assert "javascript" in resp.headers.get("content-type", "")
def test_cors_header_allows_any_origin(self, client):
resp = client.get("/widget.js")
assert resp.headers.get("access-control-allow-origin") == "*"
def test_cache_control_is_set(self, client):
resp = client.get("/widget.js")
cc = resp.headers.get("cache-control", "")
assert "public" in cc
assert "max-age" in cc
def test_x_content_type_options(self, client):
resp = client.get("/widget.js")
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_body_contains_app_url(self, client):
resp = client.get("/widget.js")
# The test client uses the real settings.app_url baked into the JS
assert "/chat/" in resp.text
def test_body_does_not_contain_placeholder(self, client):
resp = client.get("/widget.js")
assert "__APP_URL__" not in resp.text
def test_body_contains_public_api(self, client):
resp = client.get("/widget.js")
assert "window.Contexta" in resp.text
def test_body_is_non_empty(self, client):
resp = client.get("/widget.js")
assert len(resp.text) > 200
def test_no_auth_required(self, client):
"""Widget.js must be publicly accessible — no Authorization header."""
resp = client.get("/widget.js")
assert resp.status_code == 200
def test_get_only(self, client):
"""Should not accept POST."""
resp = client.post("/widget.js")
assert resp.status_code == 405