"""Tests for app/views/onboarding.py covering _get_configured_destinations() and onboarding_page() route.""" from unittest.mock import MagicMock, patch import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.database import Base from app.models import UserProfile # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_engine(): """Return an in-memory SQLite engine with all tables created.""" engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) return engine def _make_session(engine): """Return a plain session for *engine*.""" return sessionmaker(bind=engine)() def _make_profile(session, user_id: str, **kwargs) -> UserProfile: """Insert a UserProfile row and return it.""" kwargs.setdefault("is_blocked", False) kwargs.setdefault("onboarding_completed", False) profile = UserProfile(user_id=user_id, **kwargs) session.add(profile) session.commit() session.refresh(profile) return profile def _make_mock_request(session_user: dict | None = None) -> MagicMock: """Build a minimal mock FastAPI Request with a controllable session.""" mock_req = MagicMock() if session_user is None: mock_req.session = {} else: mock_req.session = {"user": session_user} return mock_req # --------------------------------------------------------------------------- # Tests for _get_configured_destinations # --------------------------------------------------------------------------- @pytest.mark.unit class TestGetConfiguredDestinations: """Unit tests for the _get_configured_destinations helper.""" def test_returns_empty_when_nothing_configured(self): """No credentials set → empty list.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) assert result == [] def test_returns_dropbox_when_configured(self): """Dropbox credentials set → Dropbox included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = "token" cfg.dropbox_app_key = "key" cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "dropbox" in ids def test_returns_gdrive_when_credentials_json_configured(self): """Google Drive credentials_json set → gdrive included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = '{"type": "service_account"}' cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "gdrive" in ids def test_returns_gdrive_when_refresh_token_configured(self): """Google Drive refresh_token set (without credentials_json) → gdrive included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = "refresh-token" cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "gdrive" in ids def test_returns_onedrive_when_configured(self): """OneDrive credentials set → onedrive included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = "token" cfg.onedrive_client_id = "client-id" cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "onedrive" in ids def test_returns_s3_when_configured(self): """S3 credentials set → s3 included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" cfg.s3_bucket_name = "my-bucket" cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "s3" in ids def test_returns_nextcloud_when_configured(self): """Nextcloud credentials set → nextcloud included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = "https://cloud.example.com" cfg.nextcloud_username = "admin" cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "nextcloud" in ids def test_returns_webdav_when_configured(self): """WebDAV credentials set → webdav included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = "https://dav.example.com" cfg.webdav_username = "user" cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "webdav" in ids def test_returns_sftp_when_configured(self): """SFTP credentials set → sftp included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = "sftp.example.com" cfg.sftp_username = "user" cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "sftp" in ids def test_returns_ftp_when_configured(self): """FTP credentials set → ftp included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = None cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = "ftp.example.com" cfg.ftp_username = "user" cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "ftp" in ids def test_returns_multiple_when_all_configured(self): """All credentials set → all eight destinations returned in order.""" from app.views.onboarding import _DESTINATION_META, _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = "token" cfg.dropbox_app_key = "key" cfg.google_drive_credentials_json = '{"type": "service_account"}' cfg.google_drive_refresh_token = "token" cfg.onedrive_refresh_token = "token" cfg.onedrive_client_id = "client-id" cfg.aws_access_key_id = "key" cfg.s3_bucket_name = "bucket" cfg.nextcloud_upload_url = "https://nc.example.com" cfg.nextcloud_username = "admin" cfg.webdav_url = "https://dav.example.com" cfg.webdav_username = "user" cfg.sftp_host = "sftp.example.com" cfg.sftp_username = "sftpuser" cfg.ftp_host = "ftp.example.com" cfg.ftp_username = "ftpuser" cfg.icloud_username = "user@example.com" cfg.icloud_password = "app-pass" result = _get_configured_destinations(cfg) assert len(result) == len(_DESTINATION_META) def test_result_contains_required_keys(self): """Each destination dict must have id, name, and icon keys.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() cfg.dropbox_refresh_token = "token" cfg.dropbox_app_key = "key" cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) assert len(result) == 1 assert set(result[0].keys()) == {"id", "name", "icon"} def test_partial_credentials_not_included(self): """Only one of a required credential pair set → destination not included.""" from app.views.onboarding import _get_configured_destinations cfg = MagicMock() # Dropbox needs both refresh_token AND app_key; only token provided cfg.dropbox_refresh_token = "token" cfg.dropbox_app_key = None cfg.google_drive_credentials_json = None cfg.google_drive_refresh_token = None cfg.onedrive_refresh_token = None cfg.onedrive_client_id = None cfg.aws_access_key_id = None cfg.s3_bucket_name = None cfg.nextcloud_upload_url = None cfg.nextcloud_username = None cfg.webdav_url = None cfg.webdav_username = None cfg.sftp_host = None cfg.sftp_username = None cfg.ftp_host = None cfg.ftp_username = None cfg.icloud_username = None cfg.icloud_password = None result = _get_configured_destinations(cfg) ids = [d["id"] for d in result] assert "dropbox" not in ids # --------------------------------------------------------------------------- # Tests for onboarding_page view # --------------------------------------------------------------------------- @pytest.mark.unit class TestOnboardingPage: """Unit tests for the onboarding_page view handler.""" @pytest.mark.asyncio async def test_no_user_renders_template(self): """No user in session → template is rendered (no redirect).""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) mock_req = _make_mock_request(session_user=None) mock_template_response = MagicMock() try: with ( patch("app.views.onboarding.templates") as mock_templates, patch("app.views.onboarding.get_all_tiers", return_value=[]), patch("app.views.onboarding._get_configured_destinations", return_value=[]), ): mock_templates.TemplateResponse.return_value = mock_template_response result = await onboarding_page(mock_req, db) assert not isinstance(result, RedirectResponse) mock_templates.TemplateResponse.assert_called_once() call_args = mock_templates.TemplateResponse.call_args assert call_args[0][0] == "onboarding.html" finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_user_without_profile_renders_template(self): """User in session but no UserProfile row → template is rendered.""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) mock_req = _make_mock_request(session_user={"sub": "user-no-profile"}) mock_template_response = MagicMock() try: with ( patch("app.views.onboarding.templates") as mock_templates, patch("app.views.onboarding.get_all_tiers", return_value=[]), patch("app.views.onboarding._get_configured_destinations", return_value=[]), ): mock_templates.TemplateResponse.return_value = mock_template_response result = await onboarding_page(mock_req, db) assert not isinstance(result, RedirectResponse) mock_templates.TemplateResponse.assert_called_once() finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_incomplete_onboarding_renders_template(self): """User has a profile with onboarding_completed=False → template rendered.""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) _make_profile(db, "user-incomplete", onboarding_completed=False) mock_req = _make_mock_request(session_user={"sub": "user-incomplete"}) mock_template_response = MagicMock() try: with ( patch("app.views.onboarding.templates") as mock_templates, patch("app.views.onboarding.get_all_tiers", return_value=[]), patch("app.views.onboarding._get_configured_destinations", return_value=[]), ): mock_templates.TemplateResponse.return_value = mock_template_response result = await onboarding_page(mock_req, db) assert not isinstance(result, RedirectResponse) mock_templates.TemplateResponse.assert_called_once() finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_completed_onboarding_redirects_via_sub(self): """User (identified by 'sub') with onboarding_completed=True → redirected to /upload.""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) _make_profile(db, "user-done-sub", onboarding_completed=True) mock_req = _make_mock_request(session_user={"sub": "user-done-sub"}) try: result = await onboarding_page(mock_req, db) assert isinstance(result, RedirectResponse) assert result.status_code == 302 assert result.headers["location"] == "/upload" finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_completed_onboarding_redirects_via_preferred_username(self): """User identified by 'preferred_username' with onboarding_completed=True → redirect.""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) _make_profile(db, "user-done-username", onboarding_completed=True) # sub is absent; preferred_username is used instead mock_req = _make_mock_request(session_user={"preferred_username": "user-done-username"}) try: result = await onboarding_page(mock_req, db) assert isinstance(result, RedirectResponse) assert result.status_code == 302 finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_completed_onboarding_redirects_via_email(self): """User identified by 'email' with onboarding_completed=True → redirect.""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) _make_profile(db, "done@example.com", onboarding_completed=True) mock_req = _make_mock_request(session_user={"email": "done@example.com"}) try: result = await onboarding_page(mock_req, db) assert isinstance(result, RedirectResponse) assert result.status_code == 302 finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_completed_onboarding_redirects_via_id(self): """User identified by 'id' with onboarding_completed=True → redirect.""" from starlette.responses import RedirectResponse from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) _make_profile(db, "user-id-42", onboarding_completed=True) mock_req = _make_mock_request(session_user={"id": "user-id-42"}) try: result = await onboarding_page(mock_req, db) assert isinstance(result, RedirectResponse) assert result.status_code == 302 finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.mark.asyncio async def test_template_context_contains_required_keys(self): """The template context passed to TemplateResponse has the correct keys.""" from app.views.onboarding import onboarding_page engine = _make_engine() db = _make_session(engine) user = {"sub": "ctx-user", "name": "Context User"} mock_req = _make_mock_request(session_user=user) mock_template_response = MagicMock() try: with ( patch("app.views.onboarding.templates") as mock_templates, patch("app.views.onboarding.get_all_tiers", return_value=["free"]), patch("app.views.onboarding._get_configured_destinations", return_value=[{"id": "s3"}]), ): mock_templates.TemplateResponse.return_value = mock_template_response await onboarding_page(mock_req, db) call_args = mock_templates.TemplateResponse.call_args context = call_args[0][1] assert context["request"] is mock_req assert context["user"] == user assert context["configured_destinations"] == [{"id": "s3"}] assert context["tiers"] == ["free"] finally: db.close() Base.metadata.drop_all(bind=engine)