+ {% if user_mode and has_system_credentials %}
+
+
-
-
+
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
{% endblock %}
diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html
index b3c47219..cc6144d7 100644
--- a/frontend/templates/profile.html
+++ b/frontend/templates/profile.html
@@ -343,9 +343,203 @@
+
+
+
+ {{ _("sessions.security_heading") }}
+
+
+ {{ _("sessions.security_subtitle") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ _("sessions.current_session") }}
+
+
+
+
+
+
+ {{ _("sessions.last_active") }}
+
+
+
+
+
+
+
+
+
{{ _("sessions.no_other_sessions") }}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/frontend/templates/signup.html b/frontend/templates/signup.html
index 1cb3a043..6f83d664 100644
--- a/frontend/templates/signup.html
+++ b/frontend/templates/signup.html
@@ -32,6 +32,14 @@
error: '',
async submit() {
this.error = '';
+ if (this.username.length < 3 || this.username.length > 64) {
+ this.error = 'Username must be between 3 and 64 characters.';
+ return;
+ }
+ if (!/^[a-zA-Z0-9_-]+$/.test(this.username)) {
+ this.error = 'Username may only contain letters, numbers, hyphens, and underscores. Dots and other special characters are not allowed.';
+ return;
+ }
if (this.password !== this.password_confirm) {
this.error = 'Passwords do not match.';
return;
@@ -58,7 +66,12 @@
}
} else {
const data = await resp.json();
- this.error = data.detail || 'Registration failed. Please try again.';
+ const detail = data.detail;
+ if (Array.isArray(detail)) {
+ this.error = detail.map(e => e.msg || String(e)).join(' ') || 'Registration failed. Please try again.';
+ } else {
+ this.error = detail || 'Registration failed. Please try again.';
+ }
}
} catch(e) {
this.error = 'Network error. Please try again.';
diff --git a/frontend/templates/system_reset.html b/frontend/templates/system_reset.html
new file mode 100644
index 00000000..452a8142
--- /dev/null
+++ b/frontend/templates/system_reset.html
@@ -0,0 +1,261 @@
+{% extends "base.html" %}
+{% block title %}{{ _("system_reset.page_title") }} - DocuElevate{% endblock %}
+
+{% block head_extra %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {{ _("system_reset.heading") }}
+
+
+
+ {{ _("system_reset.subtitle") }}
+
+
+
+
+ {% if factory_reset_on_startup %}
+
+
+
+
+
{{ _("system_reset.startup_reset_active") }}
+
{{ _("system_reset.startup_reset_desc") }}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
{{ _("system_reset.danger_zone") }}
+
{{ _("system_reset.danger_desc") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ _("system_reset.full_reset_title") }}
+
{{ _("system_reset.full_reset_subtitle") }}
+
+
+
+
+
{{ _("system_reset.full_reset_desc") }}
+
+ - {{ _("system_reset.full_reset_item_db") }}
+ - {{ _("system_reset.full_reset_item_files") }}
+ - {{ _("system_reset.full_reset_item_cache") }}
+ - {{ _("system_reset.full_reset_item_settings_kept") }}
+
+
+
+
+
+
+
{{ _("system_reset.type_delete_help") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ _("system_reset.reimport_title") }}
+
{{ _("system_reset.reimport_subtitle") }}
+
+
+
+
+
{{ _("system_reset.reimport_desc") }}
+
+ - {{ _("system_reset.reimport_step_1") }}
+ - {{ _("system_reset.reimport_step_2") }}
+ - {{ _("system_reset.reimport_step_3") }}
+
+
{{ _("system_reset.reimport_note") }}
+
+
+
+
+
+
{{ _("system_reset.type_reimport_help") }}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/frontend/translations/en.json b/frontend/translations/en.json
index 19e3a668..dbb9f8fb 100644
--- a/frontend/translations/en.json
+++ b/frontend/translations/en.json
@@ -316,6 +316,7 @@
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"api_tokens.col_created": "Created",
+ "api_tokens.col_expires": "Expires",
"api_tokens.col_last_ip": "Last IP",
"api_tokens.col_last_used": "Last Used",
"api_tokens.col_name": "Name",
@@ -327,6 +328,14 @@
"api_tokens.create_heading": "Create New Token",
"api_tokens.create_token": "Create Token",
"api_tokens.creating": "Creating…",
+ "api_tokens.delete": "Delete",
+ "api_tokens.delete_confirm": "Permanently delete this revoked token? This cannot be undone.",
+ "api_tokens.delete_prefix": "Permanently delete token",
+ "api_tokens.expires_at_label": "Expires (optional)",
+ "api_tokens.expires_at_placeholder": "e.g. 30, 90, 365 days",
+ "api_tokens.expires_in_days_label": "Token lifetime (days)",
+ "api_tokens.expires_never": "Never",
+ "api_tokens.expires_on": "Expires",
"api_tokens.heading": "API Tokens",
"api_tokens.intro": "Create personal API tokens to interact with the DocuElevate API programmatically. Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload or retrieve documents.",
"api_tokens.loading_tokens": "Loading tokens…",
@@ -334,9 +343,13 @@
"api_tokens.no_tokens_heading": "No API tokens yet",
"api_tokens.no_tokens_help": "Create your first token above to get started.",
"api_tokens.page_title": "API Tokens – DocuElevate",
+ "api_tokens.reactivate": "Reactivate",
+ "api_tokens.reactivate_confirm": "Reactivate this token? It will be usable again immediately.",
+ "api_tokens.reactivate_prefix": "Reactivate token",
"api_tokens.revoke": "Revoke",
"api_tokens.revoke_prefix": "Revoke token",
"api_tokens.status_active": "Active",
+ "api_tokens.status_expired": "Expired",
"api_tokens.status_revoked": "Revoked",
"api_tokens.table_aria": "API Tokens",
"api_tokens.token_created": "Token created successfully!",
@@ -608,6 +621,46 @@
"dashboard.title": "Dashboard",
"dashboard.total_files": "Total Files",
"dashboard.welcome": "Welcome to DocuElevate",
+ "devices.col_created": "Connected",
+ "devices.col_device": "Device",
+ "devices.col_last_ip": "Last IP",
+ "devices.col_last_seen": "Last Seen",
+ "devices.col_last_used": "Last Used",
+ "devices.col_platform": "Platform",
+ "devices.col_push_token": "Push Token",
+ "devices.col_status": "Status",
+ "devices.col_token_prefix": "Token Prefix",
+ "devices.confirm_deactivate_device": "Remove this device? It will stop receiving push notifications.",
+ "devices.confirm_revoke_token": "Revoke access for this device? It will need to log in again.",
+ "devices.deactivate_device": "Remove",
+ "devices.delete_device": "Delete",
+ "devices.delete_device_confirm": "Permanently delete this inactive device? This cannot be undone.",
+ "devices.delete_token": "Delete",
+ "devices.delete_token_confirm": "Permanently delete this revoked token? This cannot be undone.",
+ "devices.device_deleted_success": "Device permanently deleted.",
+ "devices.device_removed_success": "Device removed successfully.",
+ "devices.heading": "Mobile Devices",
+ "devices.intro": "Manage your mobile app connections and registered devices. You can revoke access for individual devices here.",
+ "devices.loading": "Loading devices…",
+ "devices.mobile_tokens_description": "These tokens were created when you logged in via the mobile app or scanned a QR code. Revoking a token will sign the device out.",
+ "devices.mobile_tokens_heading": "Mobile App Tokens",
+ "devices.no_devices": "No registered devices",
+ "devices.no_devices_help": "Install the DocuElevate mobile app and log in to register a device for push notifications.",
+ "devices.no_mobile_tokens": "No mobile app tokens",
+ "devices.no_mobile_tokens_help": "Log in via the mobile app or scan a QR code to create a mobile token.",
+ "devices.page_title": "Devices – DocuElevate",
+ "devices.qr_login_cta": "Connect a new device via QR code",
+ "devices.reactivate_token": "Reactivate",
+ "devices.reactivate_token_confirm": "Reactivate this token? The device will be able to use it again immediately.",
+ "devices.registered_devices_description": "Devices registered for push notifications from the DocuElevate mobile app.",
+ "devices.registered_devices_heading": "Registered Devices",
+ "devices.revoke_token": "Revoke",
+ "devices.status_active": "Active",
+ "devices.status_inactive": "Inactive",
+ "devices.status_revoked": "Revoked",
+ "devices.token_deleted_success": "Token permanently deleted.",
+ "devices.token_reactivated_success": "Token reactivated successfully.",
+ "devices.token_revoked_success": "Device token revoked successfully.",
"duplicates.file_id_label": "File ID",
"duplicates.file_id_placeholder": "e.g. 42",
"duplicates.find_btn": "Find",
@@ -1152,6 +1205,7 @@
"nav.dark_mode": "Dark Mode",
"nav.dashboard": "Dashboard",
"nav.developer_docs": "Developer Docs",
+ "nav.devices": "Devices",
"nav.duplicates": "Duplicates",
"nav.file_manager": "File Manager",
"nav.files": "Files",
@@ -1184,6 +1238,7 @@
"nav.skip_to_content": "Skip to main content",
"nav.status": "Status",
"nav.subscription": "Subscription",
+ "nav.system_reset": "System Reset",
"nav.toggle_dark_mode": "Toggle dark mode",
"nav.toggle_nav": "Toggle navigation menu",
"nav.upload": "Upload",
@@ -1451,6 +1506,20 @@
"profile.theme_system": "System Default",
"profile.update_password": "Update Password",
"profile.updating": "Updating…",
+ "qr_login.claimed_device": "Device: {device_name}",
+ "qr_login.claimed_message": "QR code login successful! Your mobile device is now connected.",
+ "qr_login.description": "Scan this QR code with the DocuElevate mobile app to log in instantly.",
+ "qr_login.expired_message": "This QR code has expired. Please generate a new one.",
+ "qr_login.generate_new": "Generate New QR Code",
+ "qr_login.heading": "Mobile App QR Login",
+ "qr_login.how_it_works": "How it works",
+ "qr_login.page_title": "QR Code Login – DocuElevate",
+ "qr_login.pending_message": "Waiting for mobile app to scan…",
+ "qr_login.step_1": "Open the DocuElevate app on your phone",
+ "qr_login.step_2": "Tap \"Scan QR Code\" on the login screen",
+ "qr_login.step_3": "Point your camera at this QR code",
+ "qr_login.subtitle": "Log in to the mobile app by scanning a QR code from this page.",
+ "qr_login.time_remaining": "Expires in {seconds} seconds",
"queue.active_tasks": "Active Tasks",
"queue.auto_refresh_1": "Auto-refreshes every",
"queue.auto_refresh_2": "seconds",
@@ -1512,6 +1581,25 @@
"search.saved_label": "Saved Searches",
"search.saved_loading": "Loading...",
"search.title": "Search Documents",
+ "sessions.active_sessions": "Active Sessions",
+ "sessions.confirm_revoke_all": "This will log you out of all other devices and browsers, and revoke all API tokens. Continue?",
+ "sessions.confirm_revoke_one": "Are you sure you want to end this session?",
+ "sessions.current_session": "This device",
+ "sessions.device_info": "Device",
+ "sessions.expires": "Expires",
+ "sessions.ip_address": "IP Address",
+ "sessions.last_active": "Last active",
+ "sessions.log_off_everywhere": "Log Off All Other Sessions",
+ "sessions.log_off_everywhere_desc": "End all other browser sessions and revoke all API tokens. Your current session will remain active.",
+ "sessions.no_other_sessions": "No other active sessions found.",
+ "sessions.qr_login_link": "Log in on mobile via QR code",
+ "sessions.revoke": "End Session",
+ "sessions.revoked_all_success": "All other sessions have been ended.",
+ "sessions.revoked_success": "Session ended successfully.",
+ "sessions.security_heading": "Security & Sessions",
+ "sessions.security_subtitle": "Manage your active sessions across devices and browsers.",
+ "sessions.session_lifetime": "Session lifetime: {days} days",
+ "sessions.started": "Started",
"settings.audit_log_btn": "Audit Log",
"settings.autocomplete_hint": "Type to search known values, or enter any custom value.",
"settings.autocomplete_no_matches": "No matches — you can still type a custom value",
@@ -1723,6 +1811,36 @@
"subscription.upgrade_info": "Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.",
"subscription.upgrade_to_prefix": "Upgrade to",
"subscription.usage_heading": "Usage",
+ "system_reset.danger_desc": "The actions below will permanently destroy data. They cannot be undone. Application settings and configuration are preserved, but all documents, files, processing history, and audit logs will be deleted.",
+ "system_reset.danger_zone": "Danger Zone — Irreversible Actions",
+ "system_reset.full_reset_button": "Wipe All Data",
+ "system_reset.full_reset_desc": "Permanently deletes all user data from the database and removes all work files from disk. The application will be in its initial state after this operation.",
+ "system_reset.full_reset_item_cache": "All watch-folder caches and ingestion state",
+ "system_reset.full_reset_item_db": "All document records, processing logs, and audit history",
+ "system_reset.full_reset_item_files": "All original, processed, and temporary files on disk",
+ "system_reset.full_reset_item_settings_kept": "Application settings and configuration are preserved",
+ "system_reset.full_reset_subtitle": "Wipe everything and start fresh",
+ "system_reset.full_reset_title": "Full System Reset",
+ "system_reset.heading": "System Reset",
+ "system_reset.js_error_generic": "An error occurred. Please check the server logs for details.",
+ "system_reset.js_success_full": "System reset complete. All user data has been wiped.",
+ "system_reset.js_success_reimport": "Reset complete. Original files have been staged for re-import via the watch folder.",
+ "system_reset.page_title": "System Reset",
+ "system_reset.reimport_button": "Reset & Re-import",
+ "system_reset.reimport_desc": "Copies your original files to a special reimport folder, wipes everything, then lets the watch-folder mechanism re-process them as if they were freshly uploaded.",
+ "system_reset.reimport_note": "Re-imported files will go through the full processing pipeline with the same rate limits and backoff strategy as regular uploads.",
+ "system_reset.reimport_step_1": "Original files are copied to a dedicated reimport folder",
+ "system_reset.reimport_step_2": "All data (database + work files) is wiped clean",
+ "system_reset.reimport_step_3": "The reimport folder is configured as a watch folder for automatic re-ingestion",
+ "system_reset.reimport_subtitle": "Wipe and re-process all original files",
+ "system_reset.reimport_title": "Reset & Re-import",
+ "system_reset.startup_reset_active": "Factory Reset on Startup is ACTIVE",
+ "system_reset.startup_reset_desc": "FACTORY_RESET_ON_STARTUP is enabled. All user data is wiped every time the application starts.",
+ "system_reset.subtitle": "Reset DocuElevate to a clean, fresh state. All user data will be permanently deleted.",
+ "system_reset.type_delete": "Type DELETE to confirm",
+ "system_reset.type_delete_help": "You must type the word DELETE in capital letters to enable the reset button.",
+ "system_reset.type_reimport": "Type REIMPORT to confirm",
+ "system_reset.type_reimport_help": "You must type the word REIMPORT in capital letters to enable the button.",
"terms.cookie_link": "Cookie Policy",
"terms.heading": "Terms of Service",
"terms.last_updated": "Last Updated:",
diff --git a/helm/docuelevate/templates/beat-deployment.yaml b/helm/docuelevate/templates/beat-deployment.yaml
new file mode 100644
index 00000000..3ed52251
--- /dev/null
+++ b/helm/docuelevate/templates/beat-deployment.yaml
@@ -0,0 +1,83 @@
+{{- /*
+ Celery Beat scheduler — publishes periodic tasks to the broker.
+ Exactly ONE replica must run; never scale this deployment.
+*/ -}}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "docuelevate.fullname" . }}-beat
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "docuelevate.labels" . | nindent 4 }}
+ app.kubernetes.io/component: beat
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate # Prevent two Beat instances from running simultaneously
+ selector:
+ matchLabels:
+ {{- include "docuelevate.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: beat
+ template:
+ metadata:
+ labels:
+ {{- include "docuelevate.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: beat
+ {{- with .Values.beat.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ serviceAccountName: {{ include "docuelevate.serviceAccountName" . }}
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.beat.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ - name: beat
+ image: {{ include "docuelevate.image" . }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ command:
+ - celery
+ - -A
+ - app.celery_worker
+ - beat
+ - --loglevel=info
+ envFrom:
+ - configMapRef:
+ name: {{ include "docuelevate.fullname" . }}-config
+ - secretRef:
+ name: {{ include "docuelevate.fullname" . }}-secret
+ {{- with .Values.beat.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ resources:
+ {{- toYaml .Values.beat.resources | nindent 12 }}
+ volumeMounts:
+ - name: workdir
+ mountPath: /workdir
+ volumes:
+ - name: workdir
+ {{- if .Values.workdir.persistence.enabled }}
+ persistentVolumeClaim:
+ claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }}
+ {{- else }}
+ emptyDir: {}
+ {{- end }}
+ {{- with .Values.beat.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.beat.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.beat.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/helm/docuelevate/templates/worker-deployment.yaml b/helm/docuelevate/templates/worker-deployment.yaml
index c698b0f0..efb09f1c 100644
--- a/helm/docuelevate/templates/worker-deployment.yaml
+++ b/helm/docuelevate/templates/worker-deployment.yaml
@@ -42,7 +42,6 @@ spec:
- -A
- app.celery_worker
- worker
- - -B
- --loglevel=info
- -Q
- document_processor,default,celery
diff --git a/helm/docuelevate/values.yaml b/helm/docuelevate/values.yaml
index 7ed056b0..de112df5 100644
--- a/helm/docuelevate/values.yaml
+++ b/helm/docuelevate/values.yaml
@@ -121,10 +121,10 @@ api:
type: ClusterIP
port: 8000
- # Liveness / readiness probes
+ # Liveness / readiness probes (unauthenticated endpoints for kubelet)
livenessProbe:
httpGet:
- path: /api/health
+ path: /api/diagnostic/healthz/live
port: 8000
initialDelaySeconds: 30
periodSeconds: 20
@@ -132,7 +132,7 @@ api:
readinessProbe:
httpGet:
- path: /api/health
+ path: /api/diagnostic/healthz/ready
port: 8000
initialDelaySeconds: 15
periodSeconds: 10
@@ -191,7 +191,36 @@ worker:
drop: ["ALL"]
# ---------------------------------------------------------------------------
-# Shared workdir volume (api + worker mount the same PVC)
+# Celery Beat scheduler (singleton — always exactly 1 replica)
+# Beat publishes periodic tasks; workers consume them from the broker.
+# ---------------------------------------------------------------------------
+beat:
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+
+ podAnnotations: {}
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
+
+ podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ fsGroup: 1000
+
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: false
+ capabilities:
+ drop: ["ALL"]
+
+# ---------------------------------------------------------------------------
+# Shared workdir volume (api + worker + beat mount the same PVC)
# ---------------------------------------------------------------------------
workdir:
persistence:
diff --git a/migrations/env.py b/migrations/env.py
index a0bc2f99..14566b67 100644
--- a/migrations/env.py
+++ b/migrations/env.py
@@ -24,6 +24,7 @@ from app.models import ( # noqa: F401
ApplicationSettings,
AuditLog,
BackupRecord,
+ ClassificationRuleModel,
ComplianceTemplate,
DocumentMetadata,
FileProcessingStep,
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 00000000..fb8a9c55
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,40 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """${message}."""
+ # Use ``op.batch_alter_table()`` for SQLite compatibility.
+ # Always check whether the table/column already exists before altering
+ # to keep migrations idempotent (safe to re-run).
+ #
+ # Example – add a column only if it is missing:
+ #
+ # conn = op.get_bind()
+ # inspector = sa.inspect(conn)
+ # if "my_table" in inspector.get_table_names():
+ # existing = {c["name"] for c in inspector.get_columns("my_table")}
+ # if "new_col" not in existing:
+ # with op.batch_alter_table("my_table") as batch_op:
+ # batch_op.add_column(sa.Column("new_col", sa.String(128), nullable=True))
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ """Reverse ${message}."""
+ ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/037_add_user_sessions_and_qr_challenges.py b/migrations/versions/037_add_user_sessions_and_qr_challenges.py
new file mode 100644
index 00000000..9610f56d
--- /dev/null
+++ b/migrations/versions/037_add_user_sessions_and_qr_challenges.py
@@ -0,0 +1,72 @@
+"""Add user_sessions and qr_login_challenges tables.
+
+Adds server-side session tracking (user_sessions) for the "log off
+everywhere" feature and per-session revocation, and QR login challenges
+(qr_login_challenges) for secure mobile app authentication via QR code.
+
+Revision ID: 037_add_user_sessions_and_qr_challenges
+Revises: 036_add_document_translation_fields
+Create Date: 2026-03-16
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "037_add_user_sessions_and_qr_challenges"
+down_revision: Union[str, None] = "036_add_document_translation_fields"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Create user_sessions and qr_login_challenges tables."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ existing_tables = set(inspector.get_table_names())
+
+ if "user_sessions" not in existing_tables:
+ op.create_table(
+ "user_sessions",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("session_token", sa.String(128), nullable=False, unique=True, index=True),
+ sa.Column("user_id", sa.String(), nullable=False, index=True),
+ sa.Column("ip_address", sa.String(45), nullable=True),
+ sa.Column("user_agent", sa.String(512), nullable=True),
+ sa.Column("device_info", sa.String(255), nullable=True),
+ sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("last_active_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ if "qr_login_challenges" not in existing_tables:
+ op.create_table(
+ "qr_login_challenges",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("challenge_token", sa.String(128), nullable=False, unique=True, index=True),
+ sa.Column("user_id", sa.String(), nullable=False, index=True),
+ sa.Column("is_claimed", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("is_cancelled", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("created_by_ip", sa.String(45), nullable=True),
+ sa.Column("claimed_by_ip", sa.String(45), nullable=True),
+ sa.Column("device_name", sa.String(255), nullable=True),
+ sa.Column("issued_token_id", sa.Integer(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ """Drop user_sessions and qr_login_challenges tables."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ existing_tables = set(inspector.get_table_names())
+
+ if "qr_login_challenges" in existing_tables:
+ op.drop_table("qr_login_challenges")
+
+ if "user_sessions" in existing_tables:
+ op.drop_table("user_sessions")
diff --git a/migrations/versions/038_add_api_token_expires_at.py b/migrations/versions/038_add_api_token_expires_at.py
new file mode 100644
index 00000000..20d932aa
--- /dev/null
+++ b/migrations/versions/038_add_api_token_expires_at.py
@@ -0,0 +1,43 @@
+"""Add expires_at column to api_tokens table.
+
+Allows API tokens to be issued with an optional lifetime. If ``expires_at``
+is set, the token is automatically rejected after that timestamp.
+
+Revision ID: 038_add_api_token_expires_at
+Revises: 037_add_user_sessions_and_qr_challenges
+Create Date: 2026-03-18
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "038_add_api_token_expires_at"
+down_revision: Union[str, None] = "037_add_user_sessions_and_qr_challenges"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Add expires_at column to api_tokens (idempotent)."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if "api_tokens" not in inspector.get_table_names():
+ return
+ existing_columns = {col["name"] for col in inspector.get_columns("api_tokens")}
+ if "expires_at" not in existing_columns:
+ op.add_column(
+ "api_tokens",
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ """Remove expires_at column from api_tokens."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if "api_tokens" not in inspector.get_table_names():
+ return
+ existing_columns = {col["name"] for col in inspector.get_columns("api_tokens")}
+ if "expires_at" in existing_columns:
+ op.drop_column("api_tokens", "expires_at")
diff --git a/migrations/versions/039_add_classification_rules.py b/migrations/versions/039_add_classification_rules.py
new file mode 100644
index 00000000..66e1aaf2
--- /dev/null
+++ b/migrations/versions/039_add_classification_rules.py
@@ -0,0 +1,46 @@
+"""Add classification_rules table for custom document classification rules.
+
+Revision ID: 039_add_classification_rules
+Revises: 038_add_api_token_expires_at
+Create Date: 2026-03-17
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "039_add_classification_rules"
+down_revision: Union[str, None] = "038_add_api_token_expires_at"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Create classification_rules table."""
+ op.create_table(
+ "classification_rules",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("owner_id", sa.String(), nullable=True),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("category", sa.String(100), nullable=False),
+ sa.Column("rule_type", sa.String(50), nullable=False),
+ sa.Column("pattern", sa.String(1000), nullable=False),
+ sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("case_sensitive", sa.Boolean(), nullable=False, server_default="0"),
+ sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("owner_id", "name", name="uq_classification_rules_owner_name"),
+ )
+ op.create_index("ix_classification_rules_id", "classification_rules", ["id"])
+ op.create_index("ix_classification_rules_owner_id", "classification_rules", ["owner_id"])
+ op.create_index("ix_classification_rules_category", "classification_rules", ["category"])
+
+
+def downgrade() -> None:
+ """Drop classification_rules table."""
+ op.drop_index("ix_classification_rules_category", "classification_rules")
+ op.drop_index("ix_classification_rules_owner_id", "classification_rules")
+ op.drop_index("ix_classification_rules_id", "classification_rules")
+ op.drop_table("classification_rules")
diff --git a/migrations/versions/037_add_automation_hooks.py b/migrations/versions/040_add_automation_hooks.py
similarity index 86%
rename from migrations/versions/037_add_automation_hooks.py
rename to migrations/versions/040_add_automation_hooks.py
index 6fbd995f..3d9b778c 100644
--- a/migrations/versions/037_add_automation_hooks.py
+++ b/migrations/versions/040_add_automation_hooks.py
@@ -1,7 +1,7 @@
"""Add automation_hooks table for Zapier / Make.com webhook subscriptions.
-Revision ID: 037_add_automation_hooks
-Revises: 036_add_document_translation_fields
+Revision ID: 040_add_automation_hooks
+Revises: 039_add_classification_rules
Create Date: 2026-03-09
"""
@@ -10,8 +10,8 @@ from typing import Union
import sqlalchemy as sa
from alembic import op
-revision: str = "037_add_automation_hooks"
-down_revision: Union[str, None] = "036_add_document_translation_fields"
+revision: str = "040_add_automation_hooks"
+down_revision: Union[str, None] = "039_add_classification_rules"
depends_on: Union[str, None] = None
diff --git a/mobile/README.md b/mobile/README.md
index 815ab301..601d71c3 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -163,6 +163,16 @@ The app registers itself as a share target so any file can be sent directly to D
The root layout (`app/_layout.tsx`) listens for incoming URLs via `Linking.addEventListener` (warm start) and `Linking.getInitialURL()` (cold start). If the URL uses the `docuelevate://` scheme it is automatically rewritten to `file://` before being forwarded. Incoming files are stored in `ShareContext` and automatically uploaded by `UploadScreen`.
+#### Handling "unmatched route" errors from "Open In…"
+
+iOS sometimes delivers the file path under the `docuelevate://` scheme:
+
+```
+docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf
+```
+
+expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. The catch-all `app/+not-found.tsx` intercepts this, detects the filesystem-path pattern, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext` deduplicates by URI to prevent double uploads.
+
**Supported iOS file types:** PDF, images (JPEG / PNG / GIF / BMP / TIFF / WebP), plain text, Word (`.docx`, `.doc`), Excel (`.xlsx`, `.xls`), PowerPoint (`.pptx`, `.ppt`), and any other file (`public.data`).
To use the share sheet:
@@ -174,6 +184,10 @@ To use the share sheet:
> **Note:** `CFBundleDocumentTypes` with `LSHandlerRank: Alternate` means DocuElevate appears in the share sheet as an option but does **not** become the default app for any file type.
+#### iOS Action Extension (future enhancement)
+
+Apps like DeepL ("Translate in DeepL") appear as **Action Extensions** in the iOS share sheet, which requires a separate Xcode target and native Swift code. This is planned as a future enhancement. The current `CFBundleDocumentTypes` approach places DocuElevate in the "Open With" row of the share sheet.
+
### Android – how it works
`app.json` declares `intentFilters` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `mimeType: "*/*"`. When a user shares a file from another app and selects DocuElevate, Android delivers the content URI through the share intent, which is captured via `Linking.getInitialURL()` and processed the same way as on iOS.
diff --git a/mobile/app.json b/mobile/app.json
index 1aa4aef0..0e755fed 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -19,12 +19,12 @@
"bundleIdentifier": "org.docuelevate.mobile",
"appleTeamId": "975U2ZESBM",
"infoPlist": {
- "NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.",
+ "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.",
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
- "UIBackgroundModes": ["fetch", "remote-notification"],
+ "UIBackgroundModes": ["remote-notification"],
"ITSAppUsesNonExemptEncryption": false,
- "LSSupportsOpeningDocumentsInPlace": true,
+ "LSSupportsOpeningDocumentsInPlace": false,
"CFBundleDocumentTypes": [
{
"CFBundleTypeName": "All Documents",
@@ -86,7 +86,29 @@
"expo-build-properties",
{
"ios": {
- "buildReactNativeFromSource": true
+ "buildReactNativeFromSource": true,
+ "privacyManifests": {
+ "NSPrivacyAccessedAPITypes": [
+ {
+ "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
+ "NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
+ },
+ {
+ "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
+ "NSPrivacyAccessedAPITypeReasons": ["C617.1"]
+ },
+ {
+ "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
+ "NSPrivacyAccessedAPITypeReasons": ["E174.1"]
+ },
+ {
+ "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
+ "NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
+ }
+ ],
+ "NSPrivacyCollectedDataTypes": [],
+ "NSPrivacyTracking": false
+ }
}
}
],
@@ -101,7 +123,7 @@
[
"expo-camera",
{
- "cameraPermission": "DocuElevate uses the camera to capture documents for upload."
+ "cameraPermission": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload."
}
],
"expo-document-picker",
diff --git a/mobile/app/(auth)/_layout.tsx b/mobile/app/(auth)/_layout.tsx
index 7040faec..e7288998 100644
--- a/mobile/app/(auth)/_layout.tsx
+++ b/mobile/app/(auth)/_layout.tsx
@@ -12,6 +12,7 @@ export default function AuthLayout() {
+
);
}
diff --git a/mobile/app/(auth)/qr-scanner.tsx b/mobile/app/(auth)/qr-scanner.tsx
new file mode 100644
index 00000000..b6c018fb
--- /dev/null
+++ b/mobile/app/(auth)/qr-scanner.tsx
@@ -0,0 +1,4 @@
+/**
+ * QR scanner route – camera-based QR code scanning for mobile login.
+ */
+export { default } from "../../src/screens/QRScannerScreen";
diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
index c115f7de..7068f4d2 100644
--- a/mobile/app/(tabs)/_layout.tsx
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { usePushNotifications } from "../../src/hooks/usePushNotifications";
import { useAuth } from "../../src/context/AuthContext";
+import { useLocale, t } from "../../src/i18n";
export default function TabLayout() {
const { isAuthenticated } = useAuth();
usePushNotifications(isAuthenticated);
+ // Subscribe to language changes so tab labels re-render when the language
+ // is switched. The `lang` variable is intentionally unused – its only
+ // purpose is to make this component a consumer of LocaleContext.
+ useLocale();
return (
(
),
@@ -48,23 +53,32 @@ export default function TabLayout() {
(
),
- headerTitle: "My Documents",
+ headerTitle: t("files.title"),
}}
/>
(
),
- headerTitle: "Profile",
+ headerTitle: t("tabs.profile"),
+ }}
+ />
+ {/* File detail screen – hidden from tab bar, accessed via navigation */}
+
diff --git a/mobile/app/(tabs)/file-detail.tsx b/mobile/app/(tabs)/file-detail.tsx
new file mode 100644
index 00000000..ad30c207
--- /dev/null
+++ b/mobile/app/(tabs)/file-detail.tsx
@@ -0,0 +1,4 @@
+/**
+ * File detail route – displays processing status and logs for a single file.
+ */
+export { default } from "../../src/screens/FileDetailScreen";
diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx
new file mode 100644
index 00000000..d88f1999
--- /dev/null
+++ b/mobile/app/+not-found.tsx
@@ -0,0 +1,154 @@
+/**
+ * Catch-all "not found" route for expo-router.
+ *
+ * This screen intercepts two different situations:
+ *
+ * 1. **iOS "Open In…" / share sheet** — iOS delivers files to the app via a
+ * `docuelevate://
` URL. expo-router strips the custom scheme and
+ * tries to match the raw filesystem path (e.g.
+ * `/private/var/mobile/Library/…/file.pdf`) as an in-app route. Because
+ * no such route exists, expo-router previously threw "unmatched route
+ * docuelevate://…" and the upload never happened.
+ *
+ * This screen detects the filesystem-path pattern, adds the file directly
+ * to `ShareContext`, and redirects to the Upload tab. `UploadScreen`
+ * picks up the pending file and begins uploading automatically.
+ *
+ * The `Linking` listener in `_layout.tsx` may also fire for the same URL;
+ * `ShareContext.addPendingFile` deduplicates by URI so the file is only
+ * uploaded once.
+ *
+ * 2. **Any other unmatched in-app route** — redirect silently to the root so
+ * the user isn't left on a blank error page.
+ */
+
+import { usePathname, useRouter } from "expo-router";
+import React, { useEffect } from "react";
+import { ActivityIndicator, StyleSheet, View } from "react-native";
+import { useShare } from "../src/context/ShareContext";
+import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * First path-segment names that identify iOS/Android sandbox filesystem paths.
+ * These can never be expo-router route-group names, so their presence is a
+ * strong positive signal that the URL is a shared file rather than a route.
+ *
+ * iOS: /private/var/mobile/… → "private"
+ * /var/mobile/… → "var" (symlink to /private/var/mobile)
+ * /tmp/… → "tmp"
+ * Android: /data/user/0/… → "data"
+ * /storage/emulated/0/… → "storage"
+ */
+const FS_PATH_ROOTS = ["private", "var", "tmp", "data", "storage"];
+
+/**
+ * Route-group / special-file prefixes that identify genuine in-app routes
+ * rather than filesystem path segments.
+ *
+ * ⚠️ Keep this list in sync with the top-level entries in the `app/`
+ * directory. Add an entry here if you add a new top-level route group
+ * that does **not** use the parentheses convention.
+ */
+const IN_APP_ROUTE_PREFIXES = [
+ "(auth)", // app/(auth)/
+ "(tabs)", // app/(tabs)/
+ "_", // expo-router special files (_layout, _sitemap, …)
+ "+", // expo-router special files (+not-found, …)
+ "--", // Expo Go development proxy prefix
+];
+
+/**
+ * Return `true` when `pathname` looks like a filesystem path delivered by iOS
+ * "Open In…" (e.g. `/private/var/mobile/Library/…/file.pdf`) rather than a
+ * legitimate in-app route.
+ *
+ * Detection strategy:
+ * 1. **Positive check** – if the first path segment matches a known device
+ * filesystem root (see `FS_PATH_ROOTS`), it is definitely a file path.
+ * 2. **Fallback negative check** – if the path does not start with any known
+ * in-app route prefix (see `IN_APP_ROUTE_PREFIXES`), treat it as a file
+ * path. This is a heuristic but safe because expo-router route groups
+ * always use parentheses (e.g. `(auth)`, `(tabs)`).
+ */
+function looksLikeFilePath(pathname: string): boolean {
+ const stripped = pathname.replace(/^\/+/, "");
+ if (stripped.length === 0) return false;
+
+ // Positive signal: path starts with a known device filesystem root segment.
+ const firstSegment = stripped.split("/")[0];
+ if (FS_PATH_ROOTS.includes(firstSegment)) return true;
+
+ // Fallback: paths that start with a known in-app route prefix are routes.
+ return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix));
+}
+
+/**
+ * Extract a display filename from a filesystem path.
+ * Handles URL-encoded characters and strips query strings.
+ */
+function filenameFromPath(pathname: string): string {
+ try {
+ const decoded = decodeURIComponent(pathname);
+ const segments = decoded.split("/").filter(Boolean);
+ const last = segments[segments.length - 1] ?? "shared_file";
+ return last.split("?")[0] || "shared_file";
+ } catch {
+ return "shared_file";
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Screen component
+// ---------------------------------------------------------------------------
+
+export default function NotFoundScreen() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const { addPendingFile } = useShare();
+
+ // Guard: track which pathname has been handled so the effect does not
+ // re-fire when `router` or `addPendingFile` change identity mid-navigation.
+ const handledRef = React.useRef(null);
+
+ useEffect(() => {
+ if (handledRef.current === pathname) return; // already handled
+ handledRef.current = pathname;
+
+ if (looksLikeFilePath(pathname)) {
+ // Filesystem path from iOS "Open In…" – add the file to ShareContext
+ // and redirect to the Upload tab. UploadScreen will pick up the
+ // pending file and begin uploading automatically.
+ //
+ // The pathname from expo-router is the raw filesystem path
+ // (e.g. "/private/var/mobile/Library/…/file.pdf"). Reconstruct a
+ // file:// URI so the upload logic can read the file.
+ const fileUri = `file://${pathname}`;
+ const filename = filenameFromPath(pathname);
+ addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
+ router.replace("/(tabs)/");
+ } else {
+ // Truly unknown in-app route – fall back to the root redirect.
+ router.replace("/");
+ }
+ }, [pathname, router, addPendingFile]);
+
+ // Show a brief spinner while the redirect is in flight.
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#f9fafb",
+ },
+});
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 3d328673..0feda68f 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -9,6 +9,11 @@
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
* file:// / content:// URL is captured and forwarded to UploadScreen via
* ShareContext.
+ *
+ * The companion `+not-found.tsx` handles the case where expo-router receives
+ * a `docuelevate://` URL with a filesystem path (from iOS "Open In…") and
+ * cannot match it to a route. It adds the file directly to ShareContext and
+ * redirects to the Upload tab so the file is uploaded transparently.
*/
import * as Linking from "expo-linking";
@@ -18,6 +23,8 @@ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider, useAuth } from "../src/context/AuthContext";
import { ShareProvider, useShare } from "../src/context/ShareContext";
+import { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n";
+import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
// ---------------------------------------------------------------------------
// Helpers
@@ -26,6 +33,13 @@ import { ShareProvider, useShare } from "../src/context/ShareContext";
/** The custom URL scheme registered in app.json. */
const APP_SCHEME_PREFIX = "docuelevate://";
+/**
+ * Known deep-link path prefixes that should NOT be treated as shared files.
+ * These are in-app deep-link routes handled by their respective screens
+ * (e.g. QR login, OAuth callback).
+ */
+const DEEP_LINK_PATHS = ["qr-login", "callback"];
+
/** Extract a display filename from a file:// or content:// URI. */
function filenameFromUri(uri: string): string {
try {
@@ -43,12 +57,18 @@ function filenameFromUri(uri: string): string {
* URLs to ShareContext. Extracted as a module-level factory so the handler
* itself is created once and can be easily unit-tested without a React context.
*
- * On iOS the Share Sheet / "Open In" action may deliver the file path under
+ * On iOS the Share Sheet / "Open In…" action may deliver the file path under
* the app's custom URL scheme (`docuelevate://…/file.pdf`) instead of a plain
* `file://` URL. When that happens we rewrite the URL to `file:///…` so the
* upload logic can read the file normally.
+ *
+ * Note: expo-router also receives the same URL and will attempt to match it as
+ * an in-app route. When no route matches it renders `+not-found.tsx`, which
+ * adds the file to ShareContext directly and redirects to the Upload tab.
+ * Both this handler and `+not-found.tsx` call `addPendingFile`;
+ * `ShareContext` deduplicates by URI so the file is only uploaded once.
*/
-function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) {
+function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mimeType?: string }) => void) {
return ({ url }: { url: string }) => {
let fileUri = url;
@@ -57,13 +77,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) =
// (expo-router groups always start with "(").
if (url.startsWith(APP_SCHEME_PREFIX)) {
const path = url.slice(APP_SCHEME_PREFIX.length);
- if (path.length > 0 && !path.startsWith("(")) {
+
+ // Skip known in-app deep-link paths (e.g. qr-login, callback).
+ // These are handled by their respective screens, not the share flow.
+ const pathBase = path.split("?")[0].replace(/^\/+/, "");
+ if (DEEP_LINK_PATHS.includes(pathBase) || path.startsWith("(")) {
+ return;
+ }
+
+ if (path.length > 0) {
fileUri = "file:///" + path.replace(/^\/+/, "");
}
}
if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return;
- addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) });
+ const filename = filenameFromUri(fileUri);
+ addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
};
}
@@ -72,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) =
// ---------------------------------------------------------------------------
function AuthGuard() {
- const { isLoading, isAuthenticated } = useAuth();
+ const { isLoading, isAuthenticated, user } = useAuth();
const { addPendingFile } = useShare();
+ const { setLang } = useLocale();
const segments = useSegments();
const router = useRouter();
+ // Apply the server-side language preference whenever the user profile is
+ // loaded (on login or app resume). This syncs the language set on the
+ // desktop/web client to the mobile app. If the server language is not
+ // supported by the mobile app, we leave the current language unchanged.
+ useEffect(() => {
+ if (user?.preferred_language && isLanguageSupported(user.preferred_language)) {
+ void setLang(user.preferred_language);
+ }
+ }, [user?.preferred_language, setLang]);
+
// Listen for files shared from other apps (iOS Share Sheet / Android Intent).
// Both cold-start (app was not running) and warm-start (app in background)
// cases are handled.
@@ -118,8 +158,11 @@ function AuthGuard() {
return (
+
+ {/* +not-found handles unmatched routes such as iOS "Open In…" file paths */}
+
);
}
@@ -131,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
new file mode 100644
index 00000000..2146ea1b
--- /dev/null
+++ b/mobile/app/index.tsx
@@ -0,0 +1,17 @@
+/**
+ * Root index route – redirects to the auth flow on launch.
+ *
+ * expo-router renders this when the "/" route is matched (i.e. on cold start).
+ * Without this file, a stale default scaffold page ("Hello World") can appear
+ * if one was left behind by a previous build or Expo CLI scaffolding.
+ *
+ * The redirect targets the (auth) group; the AuthGuard in _layout.tsx will
+ * immediately forward authenticated users to (tabs).
+ */
+
+import { Redirect } from "expo-router";
+import React from "react";
+
+export default function RootIndex() {
+ return ;
+}
diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js
new file mode 100644
index 00000000..7d4d4459
--- /dev/null
+++ b/mobile/eslint.config.js
@@ -0,0 +1 @@
+module.exports = require("eslint-config-expo/flat");
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 1073f1ff..fb89fda2 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -27,6 +27,7 @@
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
+ "expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@@ -44,7 +45,7 @@
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~19.1.0",
- "eslint": "^8.57.0",
+ "eslint": "^9.0.0",
"eslint-config-expo": "~10.0.0",
"typescript": "^5.3.0"
},
@@ -1633,37 +1634,40 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/js": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
- "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@@ -2313,22 +2317,6 @@
"node": ">=18.18.0"
}
},
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
- "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
- "deprecated": "Use @eslint/config-array instead",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanwhocodes/object-schema": "^2.0.3",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
- },
- "engines": {
- "node": ">=10.10.0"
- }
- },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -2343,14 +2331,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
- "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
- "deprecated": "Use @eslint/object-schema instead",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
@@ -2667,44 +2647,6 @@
"@tybys/wasm-util": "^0.10.0"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@nolyfill/is-core-module": {
"version": "1.0.39",
"resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
@@ -5766,19 +5708,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
- "node_modules/doctrine": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
- "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -6068,60 +5997,63 @@
}
},
"node_modules/eslint": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
- "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
- "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.1",
- "@humanwhocodes/config-array": "^0.13.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
"chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
+ "cross-spawn": "^7.0.6",
"debug": "^4.3.2",
- "doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
+ "file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
"lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
+ "minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
+ "optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/eslint"
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
}
},
"node_modules/eslint-config-expo": {
@@ -6260,191 +6192,6 @@
"eslint": ">=8.10"
}
},
- "node_modules/eslint-plugin-expo/node_modules/@eslint/eslintrc": {
- "version": "3.3.5",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
- "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^6.14.0",
- "debug": "^4.3.2",
- "espree": "^10.0.1",
- "globals": "^14.0.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.1",
- "minimatch": "^3.1.5",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/@eslint/js": {
- "version": "9.39.4",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
- "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/eslint": {
- "version": "9.39.4",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
- "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.8.0",
- "@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.21.2",
- "@eslint/config-helpers": "^0.4.2",
- "@eslint/core": "^0.17.0",
- "@eslint/eslintrc": "^3.3.5",
- "@eslint/js": "9.39.4",
- "@eslint/plugin-kit": "^0.4.1",
- "@humanfs/node": "^0.16.6",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@humanwhocodes/retry": "^0.4.2",
- "@types/estree": "^1.0.6",
- "ajv": "^6.14.0",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.6",
- "debug": "^4.3.2",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^8.4.0",
- "eslint-visitor-keys": "^4.2.1",
- "espree": "^10.4.0",
- "esquery": "^1.5.0",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^8.0.0",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.5",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "jiti": "*"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/eslint-scope": {
- "version": "8.4.0",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
- "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/eslint-visitor-keys": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
- "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/espree": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
- "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.15.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^4.2.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/file-entry-cache": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
- "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^4.0.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/flat-cache": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
- "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.4"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/eslint-plugin-expo/node_modules/globals": {
- "version": "14.0.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
- "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/eslint-plugin-import": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
@@ -6586,9 +6333,9 @@
}
},
"node_modules/eslint-scope": {
- "version": "7.2.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
- "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -6596,7 +6343,7 @@
"estraverse": "^5.2.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -6615,19 +6362,45 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
- "acorn": "^8.9.0",
+ "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
+ "eslint-visitor-keys": "^4.2.1"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -7078,6 +6851,19 @@
"react-native": "*"
}
},
+ "node_modules/expo-localization": {
+ "version": "17.0.8",
+ "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
+ "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
+ "license": "MIT",
+ "dependencies": {
+ "rtl-detect": "^1.0.2"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*"
+ }
+ },
"node_modules/expo-manifests": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
@@ -7335,16 +7121,6 @@
],
"license": "BSD-3-Clause"
},
- "node_modules/fastq": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -7428,16 +7204,16 @@
}
},
"node_modules/file-entry-cache": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
- "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "flat-cache": "^3.0.4"
+ "flat-cache": "^4.0.0"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=16.0.0"
}
},
"node_modules/fill-range": {
@@ -7512,24 +7288,23 @@
}
},
"node_modules/flat-cache": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
- "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
- "keyv": "^4.5.3",
- "rimraf": "^3.0.2"
+ "keyv": "^4.5.4"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=16"
}
},
"node_modules/flatted": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
- "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -7827,16 +7602,13 @@
}
},
"node_modules/globals": {
- "version": "13.24.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "type-fest": "^0.20.2"
- },
"engines": {
- "node": ">=8"
+ "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -7877,13 +7649,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -8518,16 +8283,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
- "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
@@ -10891,27 +10646,6 @@
"inherits": "~2.0.3"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -11484,17 +11218,6 @@
"node": ">=4"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -11532,29 +11255,11 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
+ "node_modules/rtl-detect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
+ "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
+ "license": "BSD-3-Clause"
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
@@ -12483,13 +12188,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/text-table": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
- "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -12651,19 +12349,6 @@
"node": ">=4"
}
},
- "node_modules/type-fest": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
- "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index bc62426c..6037583d 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -8,7 +8,7 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
- "lint": "eslint src --ext .ts,.tsx",
+ "lint": "eslint src",
"type-check": "tsc --noEmit",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
@@ -36,6 +36,7 @@
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
+ "expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@@ -53,7 +54,7 @@
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~19.1.0",
- "eslint": "^8.57.0",
+ "eslint": "^9.0.0",
"eslint-config-expo": "~10.0.0",
"typescript": "^5.3.0"
},
diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx
index 4bbb66e7..e4e1ab84 100644
--- a/mobile/src/context/AuthContext.tsx
+++ b/mobile/src/context/AuthContext.tsx
@@ -38,6 +38,7 @@ export interface AuthState {
user: WhoAmIResponse | null;
baseUrl: string;
signIn: (serverUrl: string) => Promise;
+ signInWithQR: (serverUrl: string, challengeToken: string) => Promise;
signOut: () => Promise;
setToken: (token: string) => Promise;
}
@@ -52,6 +53,7 @@ const AuthContext = createContext({
user: null,
baseUrl: "",
signIn: async () => {},
+ signInWithQR: async () => {},
signOut: async () => {},
setToken: async () => {},
});
@@ -143,6 +145,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[setToken]
);
+ const signInWithQR = useCallback(
+ async (serverUrl: string, challengeToken: string) => {
+ const cleanUrl = serverUrl.replace(/\/$/, "");
+ await api.init(cleanUrl);
+ setBaseUrl(cleanUrl);
+
+ const deviceInfo = await _getDeviceName();
+ const resp = await api.claimQRChallenge(challengeToken, deviceInfo);
+ await setToken(resp.token);
+ },
+ [setToken]
+ );
+
const signOut = useCallback(async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
@@ -158,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user,
baseUrl,
signIn,
+ signInWithQR,
signOut,
setToken,
}}
diff --git a/mobile/src/context/ShareContext.tsx b/mobile/src/context/ShareContext.tsx
index 43643bdb..caae315c 100644
--- a/mobile/src/context/ShareContext.tsx
+++ b/mobile/src/context/ShareContext.tsx
@@ -9,6 +9,7 @@
*/
import React, { createContext, useCallback, useContext, useState } from "react";
+import { normalizeFileUri } from "../utils/normalizeUri";
export interface SharedFile {
uri: string;
@@ -32,7 +33,13 @@ export function ShareProvider({ children }: { children: React.ReactNode }) {
const [pendingFiles, setPendingFiles] = useState([]);
const addPendingFile = useCallback((file: SharedFile) => {
- setPendingFiles((prev) => [...prev, file]);
+ setPendingFiles((prev) => {
+ // Deduplicate by normalised URI so the same file is not uploaded twice
+ // when both the Linking handler (_layout.tsx) and +not-found.tsx fire.
+ const norm = normalizeFileUri(file.uri);
+ if (prev.some((f) => normalizeFileUri(f.uri) === norm)) return prev;
+ return [...prev, file];
+ });
}, []);
const clearPendingFiles = useCallback(() => {
diff --git a/mobile/src/i18n/de.json b/mobile/src/i18n/de.json
new file mode 100644
index 00000000..79589bf5
--- /dev/null
+++ b/mobile/src/i18n/de.json
@@ -0,0 +1,116 @@
+{
+ "common": {
+ "retry": "Erneut versuchen",
+ "cancel": "Abbrechen",
+ "back": "Zurück",
+ "error": "Fehler",
+ "loading": "Laden…",
+ "search": "Suchen",
+ "clear_search": "Suche löschen"
+ },
+ "welcome": {
+ "tagline": "Intelligente Dokumentenverarbeitung",
+ "description": "Dokumente einlesen, OCR durchführen, Metadaten mit KI extrahieren und Dateien in Ihren Cloud-Speicher leiten – alles in einer nahtlosen Pipeline.",
+ "get_started": "Loslegen",
+ "hint": "Verbinden Sie sich mit Ihrem selbst gehosteten oder Cloud-DocuElevate-Server.",
+ "feature_ocr_title": "OCR & Texterkennung",
+ "feature_ocr_desc": "Gescannte PDFs und Bilder automatisch in durchsuchbaren Text umwandeln.",
+ "feature_ai_title": "KI-Metadatenextraktion",
+ "feature_ai_desc": "KI klassifiziert Dokumente und extrahiert Schlüsselfelder wie Datum, Beträge und Betreff.",
+ "feature_cloud_title": "Multi-Cloud-Speicher",
+ "feature_cloud_desc": "Verarbeitete Dateien an Dropbox, Google Drive, OneDrive, S3, Nextcloud und mehr weiterleiten."
+ },
+ "login": {
+ "server_url": "Server-URL",
+ "server_url_placeholder": "https://ihr-docuelevate-server.com",
+ "sign_in_sso": "Mit SSO anmelden",
+ "scan_qr": "📱 QR-Code scannen zum Anmelden",
+ "hint": "Melden Sie sich per SSO an oder scannen Sie einen QR-Code aus der Web-App.",
+ "back": "← Zurück",
+ "or": "oder",
+ "server_url_required": "Server-URL erforderlich",
+ "server_url_required_msg": "Bitte geben Sie die URL Ihres DocuElevate-Servers ein.",
+ "invalid_url": "Ungültige URL",
+ "invalid_url_msg": "Die Server-URL muss mit http:// oder https:// beginnen",
+ "sign_in_failed": "Anmeldung fehlgeschlagen",
+ "qr_login_failed": "QR-Anmeldung fehlgeschlagen"
+ },
+ "upload": {
+ "camera": "Kamera",
+ "photos": "Fotos",
+ "files": "Dateien",
+ "camera_access_title": "Kamerazugriff erforderlich",
+ "camera_access_msg": "Bitte erlauben Sie den Kamerazugriff in den Einstellungen, um Dokumente aufzunehmen.",
+ "photo_access_title": "Fotobibliothek-Zugriff erforderlich",
+ "photo_access_msg": "Bitte erlauben Sie den Zugriff auf die Fotobibliothek in den Einstellungen.",
+ "file_picker_error": "Dateiauswahl-Fehler",
+ "file_picker_error_msg": "Dateiauswahl konnte nicht geöffnet werden",
+ "empty_title": "Tippen Sie auf Kamera, Fotos oder Dateien, um ein Dokument hochzuladen.",
+ "empty_hint": "Sie können auch Dateien aus anderen Apps direkt an DocuElevate senden.",
+ "sign_in_required": "Bitte melden Sie sich an, um Dokumente hochzuladen.",
+ "status_queued": "In der Warteschlange…",
+ "status_processing": "Wird verarbeitet…",
+ "status_completed": "Verarbeitet",
+ "status_failed": "Verarbeitung fehlgeschlagen",
+ "status_duplicate": "Duplikat – bereits verarbeitet",
+ "tap_retry": "Zum Wiederholen tippen",
+ "retry_title": "Upload wiederholen",
+ "retry_msg": "Möchten Sie den Upload von \"{filename}\" wiederholen?",
+ "capture_label": "Dokument mit Kamera aufnehmen",
+ "photo_label": "Foto aus der Bibliothek auswählen",
+ "file_label": "Datei vom Gerät auswählen"
+ },
+ "files": {
+ "title": "Meine Dokumente",
+ "search_placeholder": "Dokumente durchsuchen…",
+ "empty_title": "Noch keine Dokumente.",
+ "empty_hint": "Laden Sie ein Dokument über den Upload-Tab hoch.",
+ "search_empty": "Keine Dokumente gefunden.",
+ "search_empty_hint": "Versuchen Sie einen anderen Suchbegriff.",
+ "view_details": "Details für {filename} anzeigen"
+ },
+ "file_detail": {
+ "title": "Dateidetails",
+ "back": "Zurück zu Dateien",
+ "file_size": "Dateigröße",
+ "mime_type": "MIME-Typ",
+ "uploaded": "Hochgeladen",
+ "file_hash": "Datei-Hash",
+ "last_step": "Letzter Schritt",
+ "total_steps": "Gesamtschritte",
+ "processing_log": "Verarbeitungsprotokoll",
+ "no_logs": "Noch keine Verarbeitungsprotokolle.",
+ "file_not_found": "Datei nicht gefunden"
+ },
+ "profile": {
+ "title": "Profil",
+ "not_signed_in": "Nicht angemeldet",
+ "connection": "Verbindung",
+ "server": "Server",
+ "user_id": "Benutzer-ID",
+ "legal": "Rechtliches",
+ "privacy_policy": "Datenschutzrichtlinie",
+ "terms_of_service": "Nutzungsbedingungen",
+ "imprint": "Impressum",
+ "sign_out": "Abmelden",
+ "sign_out_title": "Abmelden",
+ "sign_out_msg": "Möchten Sie sich wirklich abmelden?",
+ "delete_account": "Konto löschen",
+ "delete_account_title": "Konto löschen",
+ "delete_account_msg": "Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
+ "could_not_open": "Konnte {page} nicht öffnen. Bitte versuchen Sie es erneut.",
+ "admin": "Admin",
+ "settings": "Einstellungen",
+ "language": "Sprache"
+ },
+ "legal": {
+ "privacy_policy": "Datenschutz",
+ "terms": "AGB",
+ "imprint": "Impressum"
+ },
+ "tabs": {
+ "upload": "Hochladen",
+ "files": "Dateien",
+ "profile": "Profil"
+ }
+}
diff --git a/mobile/src/i18n/en.json b/mobile/src/i18n/en.json
new file mode 100644
index 00000000..137503c3
--- /dev/null
+++ b/mobile/src/i18n/en.json
@@ -0,0 +1,116 @@
+{
+ "common": {
+ "retry": "Retry",
+ "cancel": "Cancel",
+ "back": "Back",
+ "error": "Error",
+ "loading": "Loading…",
+ "search": "Search",
+ "clear_search": "Clear search"
+ },
+ "welcome": {
+ "tagline": "Intelligent Document Processing",
+ "description": "Ingest documents, run OCR, extract metadata with AI, and route files to your cloud storage — all in one seamless pipeline.",
+ "get_started": "Get Started",
+ "hint": "Connect to your self-hosted or cloud DocuElevate server.",
+ "feature_ocr_title": "OCR & Text Extraction",
+ "feature_ocr_desc": "Convert scanned PDFs and images into fully searchable text automatically.",
+ "feature_ai_title": "AI Metadata Extraction",
+ "feature_ai_desc": "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
+ "feature_cloud_title": "Multi-Cloud Storage",
+ "feature_cloud_desc": "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more."
+ },
+ "login": {
+ "server_url": "Server URL",
+ "server_url_placeholder": "https://your-docuelevate-server.com",
+ "sign_in_sso": "Sign in with SSO",
+ "scan_qr": "📱 Scan QR Code to Login",
+ "hint": "Sign in via SSO or scan a QR code from the web app.",
+ "back": "← Back",
+ "or": "or",
+ "server_url_required": "Server URL required",
+ "server_url_required_msg": "Please enter the URL of your DocuElevate server.",
+ "invalid_url": "Invalid URL",
+ "invalid_url_msg": "The server URL must start with http:// or https://",
+ "sign_in_failed": "Sign-in failed",
+ "qr_login_failed": "QR Login Failed"
+ },
+ "upload": {
+ "camera": "Camera",
+ "photos": "Photos",
+ "files": "Files",
+ "camera_access_title": "Camera access required",
+ "camera_access_msg": "Please grant camera access in Settings to capture documents.",
+ "photo_access_title": "Photo library access required",
+ "photo_access_msg": "Please grant photo library access in Settings to select images.",
+ "file_picker_error": "File picker error",
+ "file_picker_error_msg": "Could not open file picker",
+ "empty_title": "Tap Camera, Photos, or Files to upload a document.",
+ "empty_hint": "You can also share files from other apps directly to DocuElevate.",
+ "sign_in_required": "Please sign in to upload documents.",
+ "status_queued": "Queued for processing…",
+ "status_processing": "Processing…",
+ "status_completed": "Processed",
+ "status_failed": "Processing failed",
+ "status_duplicate": "Duplicate – already processed",
+ "tap_retry": "Tap to retry",
+ "retry_title": "Retry Upload",
+ "retry_msg": "Do you want to retry uploading \"{filename}\"?",
+ "capture_label": "Capture document with camera",
+ "photo_label": "Select photo from library",
+ "file_label": "Pick file from device"
+ },
+ "files": {
+ "title": "My Documents",
+ "search_placeholder": "Search documents…",
+ "empty_title": "No documents yet.",
+ "empty_hint": "Upload a document from the Upload tab to get started.",
+ "search_empty": "No documents match your search.",
+ "search_empty_hint": "Try a different search term.",
+ "view_details": "View details for {filename}"
+ },
+ "file_detail": {
+ "title": "File Details",
+ "back": "Back to Files",
+ "file_size": "File Size",
+ "mime_type": "MIME Type",
+ "uploaded": "Uploaded",
+ "file_hash": "File Hash",
+ "last_step": "Last Step",
+ "total_steps": "Total Steps",
+ "processing_log": "Processing Log",
+ "no_logs": "No processing logs yet.",
+ "file_not_found": "File not found"
+ },
+ "profile": {
+ "title": "Profile",
+ "not_signed_in": "Not signed in",
+ "connection": "Connection",
+ "server": "Server",
+ "user_id": "User ID",
+ "legal": "Legal",
+ "privacy_policy": "Privacy Policy",
+ "terms_of_service": "Terms of Service",
+ "imprint": "Imprint",
+ "sign_out": "Sign out",
+ "sign_out_title": "Sign out",
+ "sign_out_msg": "Are you sure you want to sign out?",
+ "delete_account": "Delete Account",
+ "delete_account_title": "Delete Account",
+ "delete_account_msg": "This will permanently delete your account and all associated data. This action cannot be undone.",
+ "could_not_open": "Could not open the {page}. Please try again.",
+ "admin": "Admin",
+ "settings": "Settings",
+ "language": "Language"
+ },
+ "legal": {
+ "privacy_policy": "Privacy Policy",
+ "terms": "Terms",
+ "imprint": "Imprint"
+ },
+ "tabs": {
+ "upload": "Upload",
+ "files": "Files",
+ "profile": "Profile"
+ }
+}
diff --git a/mobile/src/i18n/es.json b/mobile/src/i18n/es.json
new file mode 100644
index 00000000..eb434f34
--- /dev/null
+++ b/mobile/src/i18n/es.json
@@ -0,0 +1,116 @@
+{
+ "common": {
+ "retry": "Reintentar",
+ "cancel": "Cancelar",
+ "back": "Atrás",
+ "error": "Error",
+ "loading": "Cargando…",
+ "search": "Buscar",
+ "clear_search": "Borrar búsqueda"
+ },
+ "welcome": {
+ "tagline": "Procesamiento Inteligente de Documentos",
+ "description": "Ingiere documentos, ejecuta OCR, extrae metadatos con IA y envía archivos a tu almacenamiento en la nube — todo en una sola línea de trabajo.",
+ "get_started": "Comenzar",
+ "hint": "Conéctate a tu servidor DocuElevate autoalojado o en la nube.",
+ "feature_ocr_title": "OCR y Extracción de Texto",
+ "feature_ocr_desc": "Convierte PDFs e imágenes escaneadas en texto completamente buscable automáticamente.",
+ "feature_ai_title": "Extracción de Metadatos con IA",
+ "feature_ai_desc": "La IA clasifica documentos y extrae campos clave como fechas, montos y asuntos.",
+ "feature_cloud_title": "Almacenamiento Multi-Nube",
+ "feature_cloud_desc": "Envía archivos procesados a Dropbox, Google Drive, OneDrive, S3, Nextcloud y más."
+ },
+ "login": {
+ "server_url": "URL del Servidor",
+ "server_url_placeholder": "https://tu-servidor-docuelevate.com",
+ "sign_in_sso": "Iniciar sesión con SSO",
+ "scan_qr": "📱 Escanear código QR para iniciar sesión",
+ "hint": "Inicia sesión mediante SSO o escanea un código QR desde la app web.",
+ "back": "← Atrás",
+ "or": "o",
+ "server_url_required": "URL del servidor requerida",
+ "server_url_required_msg": "Por favor ingresa la URL de tu servidor DocuElevate.",
+ "invalid_url": "URL inválida",
+ "invalid_url_msg": "La URL del servidor debe comenzar con http:// o https://",
+ "sign_in_failed": "Error al iniciar sesión",
+ "qr_login_failed": "Error en inicio de sesión QR"
+ },
+ "upload": {
+ "camera": "Cámara",
+ "photos": "Fotos",
+ "files": "Archivos",
+ "camera_access_title": "Acceso a la cámara requerido",
+ "camera_access_msg": "Permite el acceso a la cámara en Ajustes para capturar documentos.",
+ "photo_access_title": "Acceso a la biblioteca de fotos requerido",
+ "photo_access_msg": "Permite el acceso a la biblioteca de fotos en Ajustes para seleccionar imágenes.",
+ "file_picker_error": "Error del selector de archivos",
+ "file_picker_error_msg": "No se pudo abrir el selector de archivos",
+ "empty_title": "Toca Cámara, Fotos o Archivos para subir un documento.",
+ "empty_hint": "También puedes compartir archivos desde otras apps directamente a DocuElevate.",
+ "sign_in_required": "Inicia sesión para subir documentos.",
+ "status_queued": "En cola para procesamiento…",
+ "status_processing": "Procesando…",
+ "status_completed": "Procesado",
+ "status_failed": "Procesamiento fallido",
+ "status_duplicate": "Duplicado – ya procesado",
+ "tap_retry": "Toca para reintentar",
+ "retry_title": "Reintentar Subida",
+ "retry_msg": "¿Deseas reintentar la subida de \"{filename}\"?",
+ "capture_label": "Capturar documento con la cámara",
+ "photo_label": "Seleccionar foto de la biblioteca",
+ "file_label": "Seleccionar archivo del dispositivo"
+ },
+ "files": {
+ "title": "Mis Documentos",
+ "search_placeholder": "Buscar documentos…",
+ "empty_title": "Aún no hay documentos.",
+ "empty_hint": "Sube un documento desde la pestaña Subir para comenzar.",
+ "search_empty": "Ningún documento coincide con tu búsqueda.",
+ "search_empty_hint": "Intenta con otro término de búsqueda.",
+ "view_details": "Ver detalles de {filename}"
+ },
+ "file_detail": {
+ "title": "Detalles del Archivo",
+ "back": "Volver a Archivos",
+ "file_size": "Tamaño",
+ "mime_type": "Tipo MIME",
+ "uploaded": "Subido",
+ "file_hash": "Hash del Archivo",
+ "last_step": "Último Paso",
+ "total_steps": "Pasos Totales",
+ "processing_log": "Registro de Procesamiento",
+ "no_logs": "Aún no hay registros de procesamiento.",
+ "file_not_found": "Archivo no encontrado"
+ },
+ "profile": {
+ "title": "Perfil",
+ "not_signed_in": "No has iniciado sesión",
+ "connection": "Conexión",
+ "server": "Servidor",
+ "user_id": "ID de Usuario",
+ "legal": "Legal",
+ "privacy_policy": "Política de Privacidad",
+ "terms_of_service": "Términos de Servicio",
+ "imprint": "Aviso Legal",
+ "sign_out": "Cerrar sesión",
+ "sign_out_title": "Cerrar sesión",
+ "sign_out_msg": "¿Estás seguro de que deseas cerrar sesión?",
+ "delete_account": "Eliminar Cuenta",
+ "delete_account_title": "Eliminar Cuenta",
+ "delete_account_msg": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.",
+ "could_not_open": "No se pudo abrir {page}. Inténtalo de nuevo.",
+ "admin": "Admin",
+ "settings": "Configuración",
+ "language": "Idioma"
+ },
+ "legal": {
+ "privacy_policy": "Privacidad",
+ "terms": "Términos",
+ "imprint": "Aviso Legal"
+ },
+ "tabs": {
+ "upload": "Subir",
+ "files": "Archivos",
+ "profile": "Perfil"
+ }
+}
diff --git a/mobile/src/i18n/fr.json b/mobile/src/i18n/fr.json
new file mode 100644
index 00000000..3760d4d5
--- /dev/null
+++ b/mobile/src/i18n/fr.json
@@ -0,0 +1,116 @@
+{
+ "common": {
+ "retry": "Réessayer",
+ "cancel": "Annuler",
+ "back": "Retour",
+ "error": "Erreur",
+ "loading": "Chargement…",
+ "search": "Rechercher",
+ "clear_search": "Effacer la recherche"
+ },
+ "welcome": {
+ "tagline": "Traitement Intelligent de Documents",
+ "description": "Ingérez des documents, lancez l'OCR, extrayez les métadonnées avec l'IA et transférez les fichiers vers votre stockage cloud — le tout dans un flux unique.",
+ "get_started": "Commencer",
+ "hint": "Connectez-vous à votre serveur DocuElevate auto-hébergé ou cloud.",
+ "feature_ocr_title": "OCR et Extraction de Texte",
+ "feature_ocr_desc": "Convertissez automatiquement les PDF scannés et les images en texte entièrement consultable.",
+ "feature_ai_title": "Extraction de Métadonnées par IA",
+ "feature_ai_desc": "L'IA classe les documents et extrait les champs clés comme les dates, montants et sujets.",
+ "feature_cloud_title": "Stockage Multi-Cloud",
+ "feature_cloud_desc": "Transférez les fichiers traités vers Dropbox, Google Drive, OneDrive, S3, Nextcloud et plus."
+ },
+ "login": {
+ "server_url": "URL du Serveur",
+ "server_url_placeholder": "https://votre-serveur-docuelevate.com",
+ "sign_in_sso": "Se connecter avec SSO",
+ "scan_qr": "📱 Scanner le code QR pour se connecter",
+ "hint": "Connectez-vous via SSO ou scannez un code QR depuis l'application web.",
+ "back": "← Retour",
+ "or": "ou",
+ "server_url_required": "URL du serveur requise",
+ "server_url_required_msg": "Veuillez entrer l'URL de votre serveur DocuElevate.",
+ "invalid_url": "URL invalide",
+ "invalid_url_msg": "L'URL du serveur doit commencer par http:// ou https://",
+ "sign_in_failed": "Échec de la connexion",
+ "qr_login_failed": "Échec de la connexion QR"
+ },
+ "upload": {
+ "camera": "Appareil photo",
+ "photos": "Photos",
+ "files": "Fichiers",
+ "camera_access_title": "Accès à l'appareil photo requis",
+ "camera_access_msg": "Veuillez autoriser l'accès à l'appareil photo dans les Réglages pour capturer des documents.",
+ "photo_access_title": "Accès à la photothèque requis",
+ "photo_access_msg": "Veuillez autoriser l'accès à la photothèque dans les Réglages pour sélectionner des images.",
+ "file_picker_error": "Erreur du sélecteur de fichiers",
+ "file_picker_error_msg": "Impossible d'ouvrir le sélecteur de fichiers",
+ "empty_title": "Appuyez sur Appareil photo, Photos ou Fichiers pour télécharger un document.",
+ "empty_hint": "Vous pouvez aussi partager des fichiers depuis d'autres applications vers DocuElevate.",
+ "sign_in_required": "Veuillez vous connecter pour télécharger des documents.",
+ "status_queued": "En file d'attente…",
+ "status_processing": "En cours de traitement…",
+ "status_completed": "Traité",
+ "status_failed": "Échec du traitement",
+ "status_duplicate": "Doublon – déjà traité",
+ "tap_retry": "Appuyez pour réessayer",
+ "retry_title": "Réessayer le téléchargement",
+ "retry_msg": "Voulez-vous réessayer le téléchargement de \"{filename}\" ?",
+ "capture_label": "Capturer un document avec l'appareil photo",
+ "photo_label": "Sélectionner une photo de la bibliothèque",
+ "file_label": "Choisir un fichier depuis l'appareil"
+ },
+ "files": {
+ "title": "Mes Documents",
+ "search_placeholder": "Rechercher des documents…",
+ "empty_title": "Pas encore de documents.",
+ "empty_hint": "Téléchargez un document depuis l'onglet Télécharger pour commencer.",
+ "search_empty": "Aucun document ne correspond à votre recherche.",
+ "search_empty_hint": "Essayez un autre terme de recherche.",
+ "view_details": "Voir les détails de {filename}"
+ },
+ "file_detail": {
+ "title": "Détails du Fichier",
+ "back": "Retour aux Fichiers",
+ "file_size": "Taille",
+ "mime_type": "Type MIME",
+ "uploaded": "Téléchargé",
+ "file_hash": "Hash du Fichier",
+ "last_step": "Dernière Étape",
+ "total_steps": "Étapes Totales",
+ "processing_log": "Journal de Traitement",
+ "no_logs": "Pas encore de journaux de traitement.",
+ "file_not_found": "Fichier non trouvé"
+ },
+ "profile": {
+ "title": "Profil",
+ "not_signed_in": "Non connecté",
+ "connection": "Connexion",
+ "server": "Serveur",
+ "user_id": "ID Utilisateur",
+ "legal": "Mentions Légales",
+ "privacy_policy": "Politique de Confidentialité",
+ "terms_of_service": "Conditions d'Utilisation",
+ "imprint": "Mentions Légales",
+ "sign_out": "Se déconnecter",
+ "sign_out_title": "Se déconnecter",
+ "sign_out_msg": "Êtes-vous sûr de vouloir vous déconnecter ?",
+ "delete_account": "Supprimer le Compte",
+ "delete_account_title": "Supprimer le Compte",
+ "delete_account_msg": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action est irréversible.",
+ "could_not_open": "Impossible d'ouvrir {page}. Veuillez réessayer.",
+ "admin": "Admin",
+ "settings": "Paramètres",
+ "language": "Langue"
+ },
+ "legal": {
+ "privacy_policy": "Confidentialité",
+ "terms": "Conditions",
+ "imprint": "Mentions Légales"
+ },
+ "tabs": {
+ "upload": "Télécharger",
+ "files": "Fichiers",
+ "profile": "Profil"
+ }
+}
diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts
new file mode 100644
index 00000000..84bc1504
--- /dev/null
+++ b/mobile/src/i18n/index.ts
@@ -0,0 +1,208 @@
+/**
+ * Lightweight i18n module for the DocuElevate mobile app.
+ *
+ * Uses the device locale (via expo-localization) to select the best matching
+ * translation file. Falls back to English for missing keys or unsupported
+ * locales.
+ *
+ * Supported languages: English, German, Spanish, French, Italian.
+ *
+ * ## React integration
+ *
+ * Wrap the app root in `` and call `useLocale()` in any
+ * component that renders translated strings. `useLocale()` returns the
+ * active language code and a `setLang` setter that:
+ * 1. Updates the in-memory `currentLanguage` variable (so `t()` picks it up)
+ * 2. Triggers a React re-render of every consumer
+ * 3. Persists the choice to AsyncStorage (survives app restarts)
+ *
+ * Language priority on startup:
+ * server preference (from /api/mobile/whoami) > AsyncStorage > device locale > "en"
+ */
+
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { getLocales } from "expo-localization";
+import React from "react";
+
+import de from "./de.json";
+import en from "./en.json";
+import es from "./es.json";
+import fr from "./fr.json";
+import it from "./it.json";
+
+// ---------------------------------------------------------------------------
+// Translation catalog
+// ---------------------------------------------------------------------------
+
+type TranslationMap = Record>;
+
+const translations: Record = { en, de, es, fr, it };
+
+// ---------------------------------------------------------------------------
+// Locale detection
+// ---------------------------------------------------------------------------
+
+const LANG_STORAGE_KEY = "@docuelevate:language";
+
+/** Resolve the best-matching language code from the device locale list. */
+function detectLanguage(): string {
+ try {
+ const locales = getLocales();
+ if (locales.length > 0) {
+ // Try exact match first (e.g. "de"), then fall back to language prefix
+ const code = locales[0].languageCode?.toLowerCase();
+ if (code && translations[code]) return code;
+ }
+ } catch {
+ // getLocales() can throw on some platforms – default to English
+ }
+ return "en";
+}
+
+let currentLanguage: string = detectLanguage();
+
+// ---------------------------------------------------------------------------
+// Plain-function public API (framework-agnostic)
+// ---------------------------------------------------------------------------
+
+/**
+ * Translate a dot-separated key, e.g. `t("upload.camera")`.
+ *
+ * Supports simple placeholder interpolation:
+ * `t("upload.retry_msg", { filename: "doc.pdf" })`
+ * replaces `{filename}` in the translated string.
+ *
+ * Falls back to the English value, then to the raw key if no translation
+ * exists.
+ */
+export function t(key: string, params?: Record): string {
+ const [section, ...rest] = key.split(".");
+ const subKey = rest.join(".");
+
+ let value =
+ translations[currentLanguage]?.[section]?.[subKey] ??
+ translations.en?.[section]?.[subKey] ??
+ key;
+
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ value = value.replaceAll(`{${k}}`, v);
+ }
+ }
+
+ return value;
+}
+
+/** Return the current language code (e.g. "en", "de"). */
+export function getLanguage(): string {
+ return currentLanguage;
+}
+
+/**
+ * Update the active language in memory.
+ * Prefer `useLocale().setLang` inside React components – it also persists
+ * the choice and triggers re-renders.
+ */
+export function setLanguage(lang: string): void {
+ if (translations[lang]) {
+ currentLanguage = lang;
+ }
+}
+
+/** Return true if the given language code is supported by the mobile app. */
+export function isLanguageSupported(lang: string): boolean {
+ return Object.prototype.hasOwnProperty.call(translations, lang);
+}
+
+/** Return the list of supported language codes. */
+export function getSupportedLanguages(): { code: string; label: string }[] {
+ return [
+ { code: "en", label: "English" },
+ { code: "de", label: "Deutsch" },
+ { code: "es", label: "Español" },
+ { code: "fr", label: "Français" },
+ { code: "it", label: "Italiano" },
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// React integration – context + provider + hook
+// ---------------------------------------------------------------------------
+
+interface LocaleContextValue {
+ /** The active language code, e.g. "en" or "de". */
+ lang: string;
+ /**
+ * Switch to a new language. Persists the choice to AsyncStorage and
+ * triggers a re-render of every `useLocale()` consumer.
+ */
+ setLang: (code: string) => Promise;
+}
+
+const LocaleContext = React.createContext({
+ lang: currentLanguage,
+ // Default setter used outside of a provider – updates in-memory only.
+ setLang: async (code: string) => {
+ setLanguage(code);
+ },
+});
+
+/**
+ * Wrap the app root in `LocaleProvider` to enable reactive language switching.
+ *
+ * On mount it reads the persisted language from AsyncStorage so the user's
+ * choice survives app restarts. The server-preferred language is applied
+ * externally (see `AuthGuard` in `app/_layout.tsx`) after the profile is
+ * fetched from `/api/mobile/whoami`.
+ */
+export function LocaleProvider({ children }: { children: React.ReactNode }): React.ReactElement {
+ const [lang, setLangState] = React.useState(currentLanguage);
+
+ // Restore the persisted language preference once on app start.
+ React.useEffect(() => {
+ AsyncStorage.getItem(LANG_STORAGE_KEY)
+ .then((saved) => {
+ if (saved && isLanguageSupported(saved)) {
+ setLanguage(saved);
+ setLangState(saved);
+ }
+ })
+ .catch(() => {
+ // Ignore read errors – fall back to device-detected language.
+ });
+ }, []);
+
+ const setLang = React.useCallback(async (code: string): Promise => {
+ if (!isLanguageSupported(code)) return;
+ setLanguage(code);
+ setLangState(code);
+ try {
+ await AsyncStorage.setItem(LANG_STORAGE_KEY, code);
+ } catch {
+ // Ignore write errors – the in-memory change is still applied.
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // setLangState is a React state setter – its identity is guaranteed stable
+
+ const value = React.useMemo(() => ({ lang, setLang }), [lang, setLang]);
+
+ return React.createElement(LocaleContext.Provider, { value }, children);
+}
+
+/**
+ * Hook that subscribes to language changes.
+ *
+ * Any component calling `useLocale()` re-renders automatically when the
+ * language changes. Call `t()` freely inside the component body – the
+ * re-render will pick up the new translations.
+ *
+ * ```tsx
+ * function MyScreen() {
+ * const { lang, setLang } = useLocale(); // subscribes to changes
+ * return {t("common.loading")};
+ * }
+ * ```
+ */
+export function useLocale(): LocaleContextValue {
+ return React.useContext(LocaleContext);
+}
diff --git a/mobile/src/i18n/it.json b/mobile/src/i18n/it.json
new file mode 100644
index 00000000..71fc3699
--- /dev/null
+++ b/mobile/src/i18n/it.json
@@ -0,0 +1,116 @@
+{
+ "common": {
+ "retry": "Riprova",
+ "cancel": "Annulla",
+ "back": "Indietro",
+ "error": "Errore",
+ "loading": "Caricamento…",
+ "search": "Cerca",
+ "clear_search": "Cancella ricerca"
+ },
+ "welcome": {
+ "tagline": "Elaborazione Intelligente dei Documenti",
+ "description": "Acquisisci documenti, esegui l'OCR, estrai metadati con l'IA e invia i file al tuo cloud storage — tutto in un unico flusso.",
+ "get_started": "Inizia",
+ "hint": "Collegati al tuo server DocuElevate self-hosted o cloud.",
+ "feature_ocr_title": "OCR ed Estrazione Testo",
+ "feature_ocr_desc": "Converti automaticamente PDF e immagini scansionate in testo completamente ricercabile.",
+ "feature_ai_title": "Estrazione Metadati con IA",
+ "feature_ai_desc": "L'IA classifica i documenti ed estrae campi chiave come date, importi e oggetti.",
+ "feature_cloud_title": "Archiviazione Multi-Cloud",
+ "feature_cloud_desc": "Invia i file elaborati a Dropbox, Google Drive, OneDrive, S3, Nextcloud e altro."
+ },
+ "login": {
+ "server_url": "URL del Server",
+ "server_url_placeholder": "https://il-tuo-server-docuelevate.com",
+ "sign_in_sso": "Accedi con SSO",
+ "scan_qr": "📱 Scansiona il codice QR per accedere",
+ "hint": "Accedi tramite SSO o scansiona un codice QR dall'app web.",
+ "back": "← Indietro",
+ "or": "o",
+ "server_url_required": "URL del server richiesto",
+ "server_url_required_msg": "Inserisci l'URL del tuo server DocuElevate.",
+ "invalid_url": "URL non valido",
+ "invalid_url_msg": "L'URL del server deve iniziare con http:// o https://",
+ "sign_in_failed": "Accesso fallito",
+ "qr_login_failed": "Accesso QR fallito"
+ },
+ "upload": {
+ "camera": "Fotocamera",
+ "photos": "Foto",
+ "files": "File",
+ "camera_access_title": "Accesso alla fotocamera richiesto",
+ "camera_access_msg": "Consenti l'accesso alla fotocamera nelle Impostazioni per acquisire documenti.",
+ "photo_access_title": "Accesso alla libreria foto richiesto",
+ "photo_access_msg": "Consenti l'accesso alla libreria foto nelle Impostazioni per selezionare immagini.",
+ "file_picker_error": "Errore nel selettore file",
+ "file_picker_error_msg": "Impossibile aprire il selettore file",
+ "empty_title": "Tocca Fotocamera, Foto o File per caricare un documento.",
+ "empty_hint": "Puoi anche condividere file da altre app direttamente su DocuElevate.",
+ "sign_in_required": "Accedi per caricare documenti.",
+ "status_queued": "In coda per l'elaborazione…",
+ "status_processing": "Elaborazione in corso…",
+ "status_completed": "Elaborato",
+ "status_failed": "Elaborazione fallita",
+ "status_duplicate": "Duplicato – già elaborato",
+ "tap_retry": "Tocca per riprovare",
+ "retry_title": "Riprova Caricamento",
+ "retry_msg": "Vuoi riprovare a caricare \"{filename}\"?",
+ "capture_label": "Acquisisci documento con la fotocamera",
+ "photo_label": "Seleziona foto dalla libreria",
+ "file_label": "Seleziona file dal dispositivo"
+ },
+ "files": {
+ "title": "I Miei Documenti",
+ "search_placeholder": "Cerca documenti…",
+ "empty_title": "Nessun documento ancora.",
+ "empty_hint": "Carica un documento dalla scheda Carica per iniziare.",
+ "search_empty": "Nessun documento corrisponde alla tua ricerca.",
+ "search_empty_hint": "Prova con un altro termine di ricerca.",
+ "view_details": "Visualizza dettagli per {filename}"
+ },
+ "file_detail": {
+ "title": "Dettagli File",
+ "back": "Torna ai File",
+ "file_size": "Dimensione",
+ "mime_type": "Tipo MIME",
+ "uploaded": "Caricato",
+ "file_hash": "Hash del File",
+ "last_step": "Ultimo Passaggio",
+ "total_steps": "Passaggi Totali",
+ "processing_log": "Registro di Elaborazione",
+ "no_logs": "Nessun registro di elaborazione ancora.",
+ "file_not_found": "File non trovato"
+ },
+ "profile": {
+ "title": "Profilo",
+ "not_signed_in": "Non connesso",
+ "connection": "Connessione",
+ "server": "Server",
+ "user_id": "ID Utente",
+ "legal": "Legale",
+ "privacy_policy": "Informativa sulla Privacy",
+ "terms_of_service": "Termini di Servizio",
+ "imprint": "Note Legali",
+ "sign_out": "Esci",
+ "sign_out_title": "Esci",
+ "sign_out_msg": "Sei sicuro di voler uscire?",
+ "delete_account": "Elimina Account",
+ "delete_account_title": "Elimina Account",
+ "delete_account_msg": "Questo eliminerà permanentemente il tuo account e tutti i dati associati. Questa azione non può essere annullata.",
+ "could_not_open": "Impossibile aprire {page}. Riprova.",
+ "admin": "Admin",
+ "settings": "Impostazioni",
+ "language": "Lingua"
+ },
+ "legal": {
+ "privacy_policy": "Privacy",
+ "terms": "Termini",
+ "imprint": "Note Legali"
+ },
+ "tabs": {
+ "upload": "Carica",
+ "files": "File",
+ "profile": "Profilo"
+ }
+}
diff --git a/mobile/src/screens/FileDetailScreen.tsx b/mobile/src/screens/FileDetailScreen.tsx
new file mode 100644
index 00000000..8d7cad20
--- /dev/null
+++ b/mobile/src/screens/FileDetailScreen.tsx
@@ -0,0 +1,332 @@
+/**
+ * FileDetailScreen – shows detailed status and processing logs for a single file.
+ *
+ * Replicates the web /files/:id and /files/:id/detail views in a
+ * mobile-friendly layout. Displays file metadata, processing status with
+ * a progress indicator, and a chronological list of processing log entries.
+ */
+
+import { Ionicons } from "@expo/vector-icons";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import React, { useCallback, useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import type { FileDetail } from "../services/api";
+import api from "../services/api";
+import { useLocale, t } from "../i18n";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function formatBytes(bytes: number | null | undefined): string {
+ if (bytes === null || bytes === undefined) return "–";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function formatDateTime(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function statusColor(status: string): string {
+ const colors: Record = {
+ completed: "#059669",
+ processing: "#d97706",
+ pending: "#6b7280",
+ failed: "#dc2626",
+ duplicate: "#6b7280",
+ };
+ return colors[status?.toLowerCase()] ?? "#6b7280";
+}
+
+function statusIcon(status: string): keyof typeof Ionicons.glyphMap {
+ const icons: Record = {
+ completed: "checkmark-circle",
+ processing: "sync-circle",
+ pending: "time-outline",
+ failed: "close-circle",
+ duplicate: "copy-outline",
+ };
+ return icons[status?.toLowerCase()] ?? "document-outline";
+}
+
+function logStepIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
+ const lower = status?.toLowerCase();
+ if (lower === "completed" || lower === "success") return { name: "checkmark-circle", color: "#059669" };
+ if (lower === "failed" || lower === "error") return { name: "close-circle", color: "#dc2626" };
+ if (lower === "skipped") return { name: "remove-circle-outline", color: "#9ca3af" };
+ if (lower === "processing" || lower === "running") return { name: "sync-circle", color: "#d97706" };
+ return { name: "ellipse-outline", color: "#6b7280" };
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export default function FileDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const [detail, setDetail] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
+
+ const fileId = parseInt(id ?? "0", 10);
+
+ const fetchDetail = useCallback(async () => {
+ if (!fileId) return;
+ try {
+ const data = await api.getFileDetail(fileId);
+ setDetail(data);
+ setError(null);
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : "Failed to load file details");
+ }
+ }, [fileId]);
+
+ useEffect(() => {
+ (async () => {
+ setLoading(true);
+ await fetchDetail();
+ setLoading(false);
+ })();
+ }, [fetchDetail]);
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await fetchDetail();
+ setRefreshing(false);
+ }, [fetchDetail]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !detail) {
+ return (
+
+ {error ?? t("file_detail.file_not_found")}
+
+ {t("common.retry")}
+
+ router.back()}>
+ {t("common.back")}
+
+
+ );
+ }
+
+ const file = detail.file;
+ const status = detail.processing_status;
+
+ return (
+ }
+ >
+ {/* Header with back button */}
+ router.back()}
+ accessibilityRole="button"
+ accessibilityLabel={t("file_detail.back")}
+ >
+
+ {t("file_detail.back")}
+
+
+ {/* File info card */}
+
+
+
+
+
+ {file.original_filename}
+
+
+ {status.status.charAt(0).toUpperCase() + status.status.slice(1)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Processing logs */}
+
+ {t("file_detail.processing_log")}
+ {detail.logs.length === 0 ? (
+ {t("file_detail.no_logs")}
+ ) : (
+ detail.logs.map((log, idx) => {
+ const icon = logStepIcon(log.status);
+ const isLast = idx === detail.logs.length - 1;
+ return (
+
+
+
+ {log.step_name}
+
+ {log.message}
+
+ {formatDateTime(log.timestamp)}
+
+
+ );
+ })
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Sub-components
+// ---------------------------------------------------------------------------
+
+function MetaRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Styles
+// ---------------------------------------------------------------------------
+
+const styles = StyleSheet.create({
+ scroll: { flex: 1, backgroundColor: "#f9fafb" },
+ content: { padding: 16, paddingBottom: 40 },
+ center: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#f9fafb",
+ padding: 24,
+ },
+ errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
+ retryButton: {
+ backgroundColor: "#1e40af",
+ borderRadius: 8,
+ paddingHorizontal: 24,
+ paddingVertical: 10,
+ marginBottom: 12,
+ },
+ retryText: { color: "#fff", fontWeight: "600" },
+ backButton: { paddingVertical: 10 },
+ backButtonText: { color: "#6b7280", fontSize: 14 },
+ backRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 16,
+ minHeight: 44,
+ },
+ backLabel: {
+ fontSize: 15,
+ color: "#1e40af",
+ fontWeight: "600",
+ marginLeft: 6,
+ },
+ card: {
+ backgroundColor: "#fff",
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 16,
+ shadowColor: "#000",
+ shadowOpacity: 0.04,
+ shadowOffset: { width: 0, height: 2 },
+ shadowRadius: 6,
+ elevation: 2,
+ },
+ cardHeader: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ marginBottom: 16,
+ },
+ filename: {
+ fontSize: 17,
+ fontWeight: "700",
+ color: "#111827",
+ marginBottom: 4,
+ },
+ statusBadge: {
+ fontSize: 13,
+ fontWeight: "600",
+ textTransform: "capitalize",
+ },
+ metaGrid: {},
+ metaRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: "#f3f4f6",
+ },
+ metaLabel: { fontSize: 13, color: "#6b7280", fontWeight: "500" },
+ metaValue: { fontSize: 13, color: "#374151", maxWidth: "55%", textAlign: "right" },
+ sectionTitle: {
+ fontSize: 15,
+ fontWeight: "700",
+ color: "#374151",
+ marginBottom: 12,
+ },
+ emptyLog: { fontSize: 13, color: "#9ca3af", fontStyle: "italic" },
+ logEntry: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ paddingVertical: 10,
+ },
+ logEntryBorder: {
+ borderBottomWidth: 1,
+ borderBottomColor: "#f3f4f6",
+ },
+ logIcon: { marginRight: 10, marginTop: 1 },
+ logContent: { flex: 1 },
+ logStep: { fontSize: 13, fontWeight: "600", color: "#374151", marginBottom: 2 },
+ logMessage: { fontSize: 12, color: "#6b7280", lineHeight: 17, marginBottom: 2 },
+ logTimestamp: { fontSize: 11, color: "#9ca3af" },
+});
diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx
index 3a2a21d4..d40b1c65 100644
--- a/mobile/src/screens/FilesScreen.tsx
+++ b/mobile/src/screens/FilesScreen.tsx
@@ -1,8 +1,10 @@
/**
- * FilesScreen – list of documents processed by DocuElevate.
+ * FilesScreen – list of documents processed by DocuElevate with search.
*/
-import React, { useCallback, useEffect, useState } from "react";
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
@@ -10,10 +12,12 @@ import {
RefreshControl,
StyleSheet,
Text,
+ TextInput,
View,
} from "react-native";
import type { FileRecord } from "../services/api";
import api from "../services/api";
+import { useLocale, t } from "../i18n";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "–";
@@ -34,29 +38,34 @@ function formatDate(iso: string): string {
}
}
-function statusEmoji(status: string): string {
- const map: Record = {
- completed: "✅",
- processing: "⚙️",
- pending: "⏳",
- failed: "❌",
- duplicate: "🔁",
+function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
+ const map: Record = {
+ completed: { name: "checkmark-circle", color: "#059669" },
+ processing: { name: "sync-circle", color: "#d97706" },
+ pending: { name: "time-outline", color: "#6b7280" },
+ failed: { name: "close-circle", color: "#dc2626" },
+ duplicate: { name: "copy-outline", color: "#6b7280" },
};
- return map[status?.toLowerCase()] ?? "📄";
+ return map[status?.toLowerCase()] ?? { name: "document-outline", color: "#6b7280" };
}
export default function FilesScreen() {
+ const router = useRouter();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const searchTimeoutRef = useRef | null>(null);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
const fetchFiles = useCallback(
- async (pageNum: number, replace: boolean) => {
+ async (pageNum: number, replace: boolean, search?: string) => {
try {
- const data = await api.listFiles(pageNum, 20);
+ const data = await api.listFiles(pageNum, 20, search || undefined);
if (replace) {
setFiles(data);
} else {
@@ -82,18 +91,56 @@ export default function FilesScreen() {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setPage(1);
- await fetchFiles(1, true);
+ await fetchFiles(1, true, searchQuery);
setRefreshing(false);
- }, [fetchFiles]);
+ }, [fetchFiles, searchQuery]);
const handleLoadMore = useCallback(async () => {
if (!hasMore || loading || refreshing) return;
const next = page + 1;
setPage(next);
- await fetchFiles(next, false);
- }, [fetchFiles, hasMore, loading, page, refreshing]);
+ await fetchFiles(next, false, searchQuery);
+ }, [fetchFiles, hasMore, loading, page, refreshing, searchQuery]);
- if (loading) {
+ const handleSearch = useCallback(
+ (text: string) => {
+ setSearchQuery(text);
+ // Debounce search requests
+ if (searchTimeoutRef.current) {
+ clearTimeout(searchTimeoutRef.current);
+ }
+ searchTimeoutRef.current = setTimeout(async () => {
+ setPage(1);
+ setLoading(true);
+ try {
+ await fetchFiles(1, true, text);
+ } finally {
+ setLoading(false);
+ }
+ }, 400);
+ },
+ [fetchFiles]
+ );
+
+ const handleClearSearch = useCallback(async () => {
+ setSearchQuery("");
+ setPage(1);
+ setLoading(true);
+ try {
+ await fetchFiles(1, true);
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchFiles]);
+
+ const handleFilePress = useCallback(
+ (file: FileRecord) => {
+ router.push({ pathname: "/(tabs)/file-detail", params: { id: String(file.id) } });
+ },
+ [router]
+ );
+
+ if (loading && files.length === 0) {
return (
@@ -101,52 +148,88 @@ export default function FilesScreen() {
);
}
- if (error) {
+ if (error && files.length === 0) {
return (
{error}
- Retry
+ {t("common.retry")}
);
}
return (
- String(item.id)}
- contentContainerStyle={styles.listContent}
- renderItem={({ item }) => }
- refreshControl={
-
- }
- onEndReached={handleLoadMore}
- onEndReachedThreshold={0.4}
- ListEmptyComponent={
-
- 📂
- No documents yet.
-
- Upload a document from the Upload tab to get started.
-
-
- }
- ListFooterComponent={
- hasMore && files.length > 0 ? (
-
- ) : null
- }
- />
+
+ {/* Search bar */}
+
+
+
+ {searchQuery.length > 0 && (
+
+
+
+ )}
+
+
+ String(item.id)}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }) => }
+ refreshControl={
+
+ }
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={0.4}
+ ListEmptyComponent={
+
+
+
+ {searchQuery ? t("files.search_empty") : t("files.empty_title")}
+
+
+ {searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
+
+
+ }
+ ListFooterComponent={
+ hasMore && files.length > 0 ? (
+
+ ) : null
+ }
+ />
+
);
}
-function FileRow({ file }: { file: FileRecord }) {
+function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
const status = file.processing_status?.status ?? "pending";
+ const icon = statusIcon(status);
return (
-
- {statusEmoji(status)}
+ onPress(file)}
+ accessibilityRole="button"
+ accessibilityLabel={`View details for ${file.original_filename}`}
+ >
+
{file.original_filename}
@@ -155,14 +238,44 @@ function FileRow({ file }: { file: FileRecord }) {
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
- {status}
-
+
+ {status}
+
+
+
);
}
const styles = StyleSheet.create({
- list: { flex: 1, backgroundColor: "#f9fafb" },
- listContent: { padding: 16 },
+ container: { flex: 1, backgroundColor: "#f9fafb" },
+ list: { flex: 1 },
+ listContent: { padding: 16, paddingTop: 0 },
+ searchContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#fff",
+ marginHorizontal: 16,
+ marginVertical: 12,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ minHeight: 44,
+ },
+ searchIcon: { marginRight: 8 },
+ searchInput: {
+ flex: 1,
+ fontSize: 15,
+ color: "#111827",
+ paddingVertical: 10,
+ },
+ clearButton: {
+ padding: 4,
+ minWidth: 44,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
center: {
flex: 1,
alignItems: "center",
@@ -179,7 +292,6 @@ const styles = StyleSheet.create({
},
retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 },
- emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: {
fontSize: 13,
@@ -203,7 +315,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
- icon: { fontSize: 22, marginRight: 12 },
+ icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
@@ -212,6 +324,11 @@ const rowStyles = StyleSheet.create({
marginBottom: 4,
},
meta: { fontSize: 12, color: "#6b7280" },
+ right: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ },
status: {
fontSize: 11,
color: "#6b7280",
diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx
index f278b46e..e92a5596 100644
--- a/mobile/src/screens/LoginScreen.tsx
+++ b/mobile/src/screens/LoginScreen.tsx
@@ -1,13 +1,16 @@
/**
- * LoginScreen – server URL entry and SSO sign-in.
+ * LoginScreen – server URL entry, SSO sign-in, and QR code login.
*
- * Renders a server URL input and a "Sign in with SSO" button that opens the
- * DocuElevate web login page in the system browser. On success the
- * AuthContext stores the API token and navigates to the main app.
+ * Renders a server URL input, a "Sign in with SSO" button that opens the
+ * DocuElevate web login page in the system browser, and a "Scan QR Code"
+ * button that opens the device camera to scan a QR code generated from the
+ * web interface. On success the AuthContext stores the API token and
+ * navigates to the main app.
*/
+import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
-import React, { useState } from "react";
+import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -21,21 +24,60 @@ import {
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
+import { useLocale, t } from "../i18n";
export default function LoginScreen() {
- const { signIn } = useAuth();
+ const { signIn, signInWithQR } = useAuth();
const router = useRouter();
- const [serverUrl, setServerUrl] = useState("");
+ const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
const [loading, setLoading] = useState(false);
+ const [qrLoading, setQrLoading] = useState(false);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
+
+ // Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
+ const handleDeepLink = useCallback(
+ async (event: { url: string }) => {
+ try {
+ const url = new URL(event.url);
+ if (url.hostname === "qr-login" || url.pathname === "/qr-login") {
+ const token = url.searchParams.get("token");
+ const server = url.searchParams.get("server");
+ if (token && server) {
+ setQrLoading(true);
+ await signInWithQR(server, token);
+ }
+ }
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : t("login.qr_login_failed");
+ Alert.alert(t("login.qr_login_failed"), message);
+ } finally {
+ setQrLoading(false);
+ }
+ },
+ [signInWithQR]
+ );
+
+ useEffect(() => {
+ // Listen for incoming deep links
+ const subscription = Linking.addEventListener("url", handleDeepLink);
+
+ // Check if the app was opened via a deep link
+ Linking.getInitialURL().then((url) => {
+ if (url) handleDeepLink({ url });
+ });
+
+ return () => subscription.remove();
+ }, [handleDeepLink]);
async function handleSignIn() {
const url = serverUrl.trim();
if (!url) {
- Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
+ Alert.alert(t("login.server_url_required"), t("login.server_url_required_msg"));
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
- Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
+ Alert.alert(t("login.invalid_url"), t("login.invalid_url_msg"));
return;
}
@@ -43,8 +85,8 @@ export default function LoginScreen() {
try {
await signIn(url);
} catch (err: unknown) {
- const message = err instanceof Error ? err.message : "Sign-in failed";
- Alert.alert("Sign-in failed", message);
+ const message = err instanceof Error ? err.message : t("login.sign_in_failed");
+ Alert.alert(t("login.sign_in_failed"), message);
} finally {
setLoading(false);
}
@@ -65,12 +107,12 @@ export default function LoginScreen() {
/>
DocuElevate
- Intelligent Document Processing
+ {t("welcome.tagline")}
- Server URL
+ {t("login.server_url")}
{loading ? (
) : (
- Sign in with SSO
+ {t("login.sign_in_sso")}
)}
-
- You will be redirected to your organisation's sign-in page.
-
+
+
+ {t("login.or")}
+
+
+
+ {
+ router.push("/(auth)/qr-scanner");
+ }}
+ disabled={loading || qrLoading}
+ accessibilityRole="button"
+ accessibilityLabel={t("login.scan_qr")}
+ >
+ {qrLoading ? (
+
+ ) : (
+ {t("login.scan_qr")}
+ )}
+
+
+ {t("login.hint")}
router.back()}
accessibilityRole="button"
- accessibilityLabel="Back to welcome screen"
+ accessibilityLabel={t("login.back")}
style={styles.backLink}
>
- ← Back
+ {t("login.back")}
+
+ {/* Legal links – accessible pre-login for GDPR / Apple compliance */}
+
+ {
+ const base = serverUrl.trim() || "https://app.docuelevate.org";
+ Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={t("legal.privacy_policy")}
+ style={styles.legalLinkButton}
+ >
+ {t("legal.privacy_policy")}
+
+ ·
+ {
+ const base = serverUrl.trim() || "https://app.docuelevate.org";
+ Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={t("legal.terms")}
+ style={styles.legalLinkButton}
+ >
+ {t("legal.terms")}
+
+ ·
+ {
+ const base = serverUrl.trim() || "https://app.docuelevate.org";
+ Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={t("legal.imprint")}
+ style={styles.legalLinkButton}
+ >
+ {t("legal.imprint")}
+
+
);
@@ -184,6 +285,36 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: "600",
},
+ dividerRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginVertical: 16,
+ },
+ dividerLine: {
+ flex: 1,
+ height: 1,
+ backgroundColor: "#e5e7eb",
+ },
+ dividerText: {
+ marginHorizontal: 12,
+ fontSize: 12,
+ color: "#9ca3af",
+ },
+ qrButton: {
+ borderWidth: 1,
+ borderColor: "#1e40af",
+ borderRadius: 8,
+ paddingVertical: 14,
+ alignItems: "center",
+ justifyContent: "center",
+ minHeight: 48,
+ backgroundColor: "#eff6ff",
+ },
+ qrButtonText: {
+ color: "#1e40af",
+ fontSize: 15,
+ fontWeight: "600",
+ },
hint: {
marginTop: 16,
fontSize: 12,
@@ -200,4 +331,26 @@ const styles = StyleSheet.create({
fontSize: 13,
color: "#6b7280",
},
+ legalLinks: {
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ marginTop: 16,
+ flexWrap: "wrap",
+ },
+ legalLinkButton: {
+ minHeight: 44,
+ justifyContent: "center",
+ paddingHorizontal: 4,
+ },
+ legalLinkText: {
+ fontSize: 12,
+ color: "#9ca3af",
+ textDecorationLine: "underline",
+ },
+ legalSeparator: {
+ fontSize: 12,
+ color: "#d1d5db",
+ marginHorizontal: 4,
+ },
});
diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx
index ecc39d72..3990e1df 100644
--- a/mobile/src/screens/ProfileScreen.tsx
+++ b/mobile/src/screens/ProfileScreen.tsx
@@ -2,6 +2,8 @@
* ProfileScreen – authenticated user profile and settings.
*/
+import Constants from "expo-constants";
+import * as Linking from "expo-linking";
import React from "react";
import {
Alert,
@@ -9,30 +11,84 @@ import {
Pressable,
ScrollView,
StyleSheet,
- Switch,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
+import { useLocale, getSupportedLanguages, t } from "../i18n";
+import api from "../services/api";
+
+const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth();
+ const { lang, setLang } = useLocale();
+
+ const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
+ const appVersion = Constants.expoConfig?.version ?? "1.0.0";
+ const languages = getSupportedLanguages();
+
+ async function handleLanguageSelect(code: string) {
+ await setLang(code);
+ // Fire-and-forget: sync the choice to the server so it persists across
+ // platforms (desktop web will reflect this preference too).
+ api.setServerLanguage(code).catch(() => {
+ // Network errors are non-critical – the local change is already applied.
+ });
+ }
function handleSignOut() {
- Alert.alert("Sign out", "Are you sure you want to sign out?", [
- { text: "Cancel", style: "cancel" },
+ Alert.alert(t("profile.sign_out_title"), t("profile.sign_out_msg"), [
+ { text: t("common.cancel"), style: "cancel" },
{
- text: "Sign out",
+ text: t("profile.sign_out"),
style: "destructive",
onPress: signOut,
},
]);
}
+ function handleDeleteAccount() {
+ Alert.alert(
+ t("profile.delete_account_title"),
+ t("profile.delete_account_msg"),
+ [
+ { text: t("common.cancel"), style: "cancel" },
+ {
+ text: t("profile.delete_account"),
+ style: "destructive",
+ onPress: () => {
+ Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
+ Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
+ });
+ },
+ },
+ ]
+ );
+ }
+
+ function openPrivacyPolicy() {
+ Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
+ Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
+ });
+ }
+
+ function openTermsOfService() {
+ Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
+ Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.terms_of_service") }));
+ });
+ }
+
+ function openImprint() {
+ Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => {
+ Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.imprint") }));
+ });
+ }
+
if (!user) {
return (
- Not signed in
+ {t("profile.not_signed_in")}
);
}
@@ -56,44 +112,121 @@ export default function ProfileScreen() {
)}
{user.display_name ?? user.owner_id}
{user.email && {user.email}}
- {user.is_admin && Admin}
+ {user.is_admin && {t("profile.admin")}}
{/* Server info */}
- Connection
+ {t("profile.connection")}
- Server
+ {t("profile.server")}
- {baseUrl || "–"}
+ {effectiveBaseUrl}
- User ID
+ {t("profile.user_id")}
{user.owner_id}
- {/* Danger zone */}
+ {/* Settings */}
+
+ {t("profile.settings")}
+ {t("profile.language")}
+
+ {languages.map((l) => (
+ handleLanguageSelect(l.code)}
+ accessibilityRole="button"
+ accessibilityLabel={`Set language to ${l.label}`}
+ accessibilityState={{ selected: lang === l.code }}
+ >
+
+ {l.label}
+
+
+ ))}
+
+
+
+ {/* Legal & Privacy */}
+
+ {t("profile.legal")}
+
+ {t("profile.privacy_policy")}
+ ›
+
+
+ {t("profile.terms_of_service")}
+ ›
+
+
+ {t("profile.imprint")}
+ ›
+
+
+
+ {/* Sign out */}
- Sign out
+ {t("profile.sign_out")}
+
+ {/* Account deletion – Apple Guideline 5.1.1(v) */}
+
+
+ {t("profile.delete_account")}
+
+
+
+ {/* App version */}
+ DocuElevate v{appVersion}
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
- content: { padding: 20 },
+ content: { padding: 20, paddingBottom: 40 },
center: {
flex: 1,
alignItems: "center",
@@ -180,6 +313,27 @@ const styles = StyleSheet.create({
maxWidth: "60%",
textAlign: "right",
},
+ linkRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: "#f3f4f6",
+ minHeight: 44,
+ },
+ linkRowLast: {
+ borderBottomWidth: 0,
+ },
+ linkText: {
+ fontSize: 15,
+ color: "#1e40af",
+ },
+ linkChevron: {
+ fontSize: 18,
+ color: "#9ca3af",
+ fontWeight: "600",
+ },
signOutButton: {
backgroundColor: "#fee2e2",
borderRadius: 10,
@@ -192,4 +346,58 @@ const styles = StyleSheet.create({
fontWeight: "700",
fontSize: 15,
},
+ deleteAccountButton: {
+ backgroundColor: "#ffffff",
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: "#dc2626",
+ paddingVertical: 14,
+ alignItems: "center",
+ minHeight: 48,
+ },
+ deleteAccountText: {
+ color: "#dc2626",
+ fontWeight: "600",
+ fontSize: 14,
+ },
+ versionText: {
+ fontSize: 12,
+ color: "#9ca3af",
+ textAlign: "center",
+ marginTop: 8,
+ },
+ settingLabel: {
+ fontSize: 14,
+ color: "#374151",
+ fontWeight: "500",
+ marginBottom: 10,
+ },
+ languageGrid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 8,
+ },
+ languageChip: {
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ backgroundColor: "#f3f4f6",
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ minHeight: 36,
+ justifyContent: "center",
+ },
+ languageChipActive: {
+ backgroundColor: "#dbeafe",
+ borderColor: "#1e40af",
+ },
+ languageChipText: {
+ fontSize: 13,
+ color: "#6b7280",
+ fontWeight: "500",
+ },
+ languageChipTextActive: {
+ color: "#1e40af",
+ fontWeight: "700",
+ },
});
diff --git a/mobile/src/screens/QRScannerScreen.tsx b/mobile/src/screens/QRScannerScreen.tsx
new file mode 100644
index 00000000..696c8e2b
--- /dev/null
+++ b/mobile/src/screens/QRScannerScreen.tsx
@@ -0,0 +1,295 @@
+/**
+ * QRScannerScreen – camera-based QR code scanner for mobile login.
+ *
+ * Opens the device camera and scans for QR codes containing a
+ * `docuelevate://qr-login?token=...&server=...` payload. On successful
+ * scan the token is claimed via the API and the user is signed in.
+ */
+
+import { CameraView, useCameraPermissions } from "expo-camera";
+import { useRouter } from "expo-router";
+import React, { useCallback, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { useAuth } from "../context/AuthContext";
+
+export default function QRScannerScreen() {
+ const { signInWithQR } = useAuth();
+ const router = useRouter();
+ const [permission, requestPermission] = useCameraPermissions();
+ const [scanned, setScanned] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const processingRef = useRef(false);
+
+ const handleBarCodeScanned = useCallback(
+ async (result: { data: string }) => {
+ // Prevent duplicate scans while processing
+ if (processingRef.current) return;
+
+ const { data } = result;
+
+ // Only accept docuelevate:// QR codes
+ if (!data.startsWith("docuelevate://qr-login")) return;
+
+ processingRef.current = true;
+ setScanned(true);
+ setProcessing(true);
+
+ try {
+ const url = new URL(data);
+ const token = url.searchParams.get("token");
+ const server = url.searchParams.get("server");
+
+ if (!token || !server) {
+ Alert.alert("Invalid QR Code", "This QR code does not contain valid login information.");
+ setScanned(false);
+ processingRef.current = false;
+ setProcessing(false);
+ return;
+ }
+
+ await signInWithQR(server, token);
+ // signInWithQR updates AuthContext → AuthGuard redirects to main app
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "QR login failed";
+ Alert.alert("QR Login Failed", message);
+ setScanned(false);
+ processingRef.current = false;
+ setProcessing(false);
+ }
+ },
+ [signInWithQR]
+ );
+
+ // Permissions not yet determined
+ if (!permission) {
+ return (
+
+
+
+ );
+ }
+
+ // Permission denied
+ if (!permission.granted) {
+ return (
+
+
+ Camera access is required to scan QR codes.
+
+
+ Grant Camera Access
+
+ router.back()}
+ style={styles.backLink}
+ accessibilityRole="button"
+ accessibilityLabel="Go back"
+ >
+ ← Back
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Overlay with scan area indicator */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {processing ? (
+
+
+ Signing in…
+
+ ) : (
+
+ Point your camera at the QR code{"\n"}shown on the DocuElevate web app
+
+ )}
+
+ router.back()}
+ style={styles.cancelButton}
+ accessibilityRole="button"
+ accessibilityLabel="Cancel QR scan"
+ >
+ Cancel
+
+
+
+
+ );
+}
+
+const SCAN_AREA_SIZE = 250;
+const CORNER_SIZE = 24;
+const CORNER_WIDTH = 3;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "#000",
+ },
+ camera: {
+ flex: 1,
+ },
+ centered: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: "#f3f4f6",
+ padding: 24,
+ },
+ permissionText: {
+ fontSize: 16,
+ color: "#374151",
+ textAlign: "center",
+ marginBottom: 20,
+ },
+ permissionButton: {
+ backgroundColor: "#1e40af",
+ borderRadius: 8,
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ minHeight: 48,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ permissionButtonText: {
+ color: "#fff",
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ backLink: {
+ marginTop: 20,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ backLinkText: {
+ fontSize: 14,
+ color: "#6b7280",
+ },
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ overlayTop: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ },
+ overlayMiddle: {
+ flexDirection: "row",
+ height: SCAN_AREA_SIZE,
+ },
+ overlaySide: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ },
+ scanArea: {
+ width: SCAN_AREA_SIZE,
+ height: SCAN_AREA_SIZE,
+ },
+ corner: {
+ position: "absolute",
+ width: CORNER_SIZE,
+ height: CORNER_SIZE,
+ },
+ cornerTopLeft: {
+ top: 0,
+ left: 0,
+ borderTopWidth: CORNER_WIDTH,
+ borderLeftWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerTopRight: {
+ top: 0,
+ right: 0,
+ borderTopWidth: CORNER_WIDTH,
+ borderRightWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerBottomLeft: {
+ bottom: 0,
+ left: 0,
+ borderBottomWidth: CORNER_WIDTH,
+ borderLeftWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ cornerBottomRight: {
+ bottom: 0,
+ right: 0,
+ borderBottomWidth: CORNER_WIDTH,
+ borderRightWidth: CORNER_WIDTH,
+ borderColor: "#fff",
+ },
+ overlayBottom: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ alignItems: "center",
+ paddingTop: 32,
+ },
+ statusContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ statusText: {
+ color: "#fff",
+ fontSize: 16,
+ fontWeight: "600",
+ marginLeft: 10,
+ },
+ instructionText: {
+ color: "#fff",
+ fontSize: 15,
+ textAlign: "center",
+ lineHeight: 22,
+ },
+ cancelButton: {
+ marginTop: 24,
+ paddingVertical: 12,
+ paddingHorizontal: 32,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.5)",
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ cancelButtonText: {
+ color: "#fff",
+ fontSize: 15,
+ fontWeight: "500",
+ },
+});
diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx
index 0a2788cf..daa00a01 100644
--- a/mobile/src/screens/UploadScreen.tsx
+++ b/mobile/src/screens/UploadScreen.tsx
@@ -12,7 +12,9 @@
* track the real-time processing status of each uploaded file.
*/
+import { Ionicons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
+import * as FileSystem from "expo-file-system";
import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -26,7 +28,9 @@ import {
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useShare } from "../context/ShareContext";
+import { normalizeFileUri } from "../utils/normalizeUri";
import api from "../services/api";
+import { useLocale, t } from "../i18n";
/** Statuses that indicate processing has finished (no further polling needed). */
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
@@ -53,6 +57,8 @@ export default function UploadScreen() {
const { isAuthenticated } = useAuth();
const { pendingFiles, clearPendingFiles } = useShare();
const [uploads, setUploads] = useState([]);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
// Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure.
@@ -61,30 +67,102 @@ export default function UploadScreen() {
uploadsRef.current = uploads;
}, [uploads]);
+ // Track URIs that have already been uploaded in this session so that
+ // duplicate share-sheet deliveries (iOS can fire both the Linking handler
+ // and +not-found.tsx for the same file) do not trigger repeated uploads.
+ const uploadedUrisRef = useRef>(new Set());
+
// ---------------------------------------------------------------------------
// Core helpers (declared before the effects that depend on them)
// ---------------------------------------------------------------------------
+ /**
+ * Ensure a file URI is accessible for upload.
+ *
+ * Files received via the iOS Share Sheet / "Open In…" may reference paths
+ * outside the app's sandbox or use security-scoped URLs that React Native's
+ * fetch cannot read directly. This helper copies such files to the app's
+ * cache directory so the upload can proceed reliably.
+ *
+ * URIs from expo-image-picker and expo-document-picker are already in the
+ * app's cache and are returned unchanged.
+ */
+ const ensureLocalUri = useCallback(async (uri: string, filename: string): Promise => {
+ // Android content:// URIs are handled natively by React Native's fetch.
+ if (!uri.startsWith("file://")) return uri;
+
+ // Files already in the app's cache or documents directory are accessible.
+ const cacheDir = FileSystem.cacheDirectory;
+ const docDir = FileSystem.documentDirectory;
+ if (cacheDir && uri.startsWith(cacheDir)) return uri;
+ if (docDir && uri.startsWith(docDir)) return uri;
+
+ // External file (e.g. from iOS Inbox or security-scoped URL) – copy to
+ // cache so the upload has guaranteed read access.
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
+ const destUri = `${cacheDir}shared_${Date.now()}_${safeName}`;
+ try {
+ await FileSystem.copyAsync({ from: uri, to: destUri });
+ return destUri;
+ } catch (copyErr) {
+ // Copy failed – fall back to the original URI (might work for some paths).
+ console.warn("[ensureLocalUri] copyAsync failed:", { from: uri, to: destUri, error: copyErr });
+ return uri;
+ }
+ }, []);
+
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
- const id = `${Date.now()}-${filename}`;
+ // Deduplicate: skip if this exact URI was already uploaded in this session.
+ // This guards against duplicate share-sheet deliveries from iOS where the
+ // Linking handler and +not-found.tsx fire for the same file.
+ const normUri = normalizeFileUri(uri);
+ if (uploadedUrisRef.current.has(normUri)) {
+ console.debug("[uploadFile] skipping duplicate URI:", uri);
+ return;
+ }
+ uploadedUrisRef.current.add(normUri);
+
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${filename}`;
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
try {
- const resp = await api.uploadFile(uri, filename, mimeType);
- setUploads((prev) =>
- prev.map((item) =>
- item.id === id
- ? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
- : item
- )
- );
+ const localUri = await ensureLocalUri(uri, filename);
+ const resp = await api.uploadFile(localUri, filename, mimeType);
+ if (resp.status === "duplicate" && resp.duplicate_of) {
+ // Server rejected the file as a known duplicate — mark as done and
+ // set the server-side status to "duplicate" so it appears as a
+ // terminal status and is not polled further.
+ setUploads((prev) =>
+ prev.map((item) =>
+ item.id === id
+ ? {
+ ...item,
+ status: "done",
+ fileId: resp.duplicate_of!.original_file_id,
+ originalFilename: resp.original_filename,
+ serverStatus: "duplicate",
+ }
+ : item
+ )
+ );
+ } else {
+ setUploads((prev) =>
+ prev.map((item) =>
+ item.id === id
+ ? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
+ : item
+ )
+ );
+ }
} catch (err: unknown) {
+ // Allow retrying this URI on failure.
+ uploadedUrisRef.current.delete(normUri);
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
);
}
- }, []);
+ }, [ensureLocalUri]);
const retryUpload = useCallback(async (item: UploadItem) => {
if (!item.uri) return;
@@ -99,21 +177,38 @@ export default function UploadScreen() {
);
try {
- const resp = await api.uploadFile(item.uri, item.filename, item.mimeType);
- setUploads((prev) =>
- prev.map((u) =>
- u.id === item.id
- ? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
- : u
- )
- );
+ const localUri = await ensureLocalUri(item.uri, item.filename);
+ const resp = await api.uploadFile(localUri, item.filename, item.mimeType);
+ if (resp.status === "duplicate" && resp.duplicate_of) {
+ setUploads((prev) =>
+ prev.map((u) =>
+ u.id === item.id
+ ? {
+ ...u,
+ status: "done",
+ fileId: resp.duplicate_of!.original_file_id,
+ originalFilename: resp.original_filename,
+ serverStatus: "duplicate",
+ }
+ : u
+ )
+ );
+ } else {
+ setUploads((prev) =>
+ prev.map((u) =>
+ u.id === item.id
+ ? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
+ : u
+ )
+ );
+ }
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u))
);
}
- }, []);
+ }, [ensureLocalUri]);
// ---------------------------------------------------------------------------
// Polling – check server-side processing status every 5 seconds
@@ -176,8 +271,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") {
Alert.alert(
- "Camera access required",
- "Please grant camera access in Settings to capture documents."
+ t("upload.camera_access_title"),
+ t("upload.camera_access_msg")
);
return;
}
@@ -199,8 +294,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(
- "Photo library access required",
- "Please grant photo library access in Settings to select images."
+ t("upload.photo_access_title"),
+ t("upload.photo_access_msg")
);
return;
}
@@ -209,14 +304,17 @@ export default function UploadScreen() {
mediaTypes: ["images"],
quality: 0.9,
allowsEditing: false,
+ allowsMultipleSelection: true,
});
if (!result.canceled && result.assets.length > 0) {
- const asset = result.assets[0];
- // Derive extension from MIME type so the filename matches the actual format
- const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
- const filename = asset.fileName ?? `photo_${Date.now()}.${ext}`;
- await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
+ for (let i = 0; i < result.assets.length; i++) {
+ const asset = result.assets[i];
+ // Derive extension from MIME type so the filename matches the actual format
+ const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
+ const filename = asset.fileName ?? `photo_${Date.now()}_${i}.${ext}`;
+ await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
+ }
}
}
@@ -234,14 +332,14 @@ export default function UploadScreen() {
}
}
} catch (err: unknown) {
- Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
+ Alert.alert(t("upload.file_picker_error"), err instanceof Error ? err.message : t("upload.file_picker_error_msg"));
}
}
if (!isAuthenticated) {
return (
- Please sign in to upload documents.
+ {t("upload.sign_in_required")}
);
}
@@ -254,30 +352,30 @@ export default function UploadScreen() {
style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera}
accessibilityRole="button"
- accessibilityLabel="Capture document with camera"
+ accessibilityLabel={t("upload.capture_label")}
>
- 📷
- Camera
+
+ {t("upload.camera")}
- 🖼️
- Photos
+
+ {t("upload.photos")}
- 📄
- Files
+
+ {t("upload.files")}
@@ -285,13 +383,9 @@ export default function UploadScreen() {
{uploads.length === 0 ? (
- ☁️
-
- Tap Camera, Photos, or Files to upload a document.
-
-
- You can also share files from other apps directly to DocuElevate.
-
+
+ {t("upload.empty_title")}
+ {t("upload.empty_hint")}
) : (
uploads.map((item) => (
@@ -304,21 +398,24 @@ export default function UploadScreen() {
}
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
- const uploadIcons: Record = {
- pending: "⏳",
- uploading: "⬆️",
- done: "✅",
- error: "❌",
+ // Subscribe to language changes so status labels re-render.
+ useLocale();
+
+ const uploadIconProps: Record = {
+ pending: { name: "time-outline", color: "#6b7280" },
+ uploading: { name: "arrow-up-circle-outline", color: "#1e40af" },
+ done: { name: "checkmark-circle", color: "#059669" },
+ error: { name: "close-circle", color: "#dc2626" },
};
/** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string {
const labels: Record = {
- pending: "Queued for processing…",
- processing: "Processing…",
- completed: "Processed ✓",
- failed: "Processing failed",
- duplicate: "Duplicate – already processed",
+ pending: t("upload.status_queued"),
+ processing: t("upload.status_processing"),
+ completed: t("upload.status_completed"),
+ failed: t("upload.status_failed"),
+ duplicate: t("upload.status_duplicate"),
};
return labels[s] ?? s;
}
@@ -327,9 +424,9 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
function handleLongPress() {
if (!canRetry) return;
- Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
- { text: "Cancel", style: "cancel" },
- { text: "Retry", onPress: () => onRetry(item) },
+ Alert.alert(t("upload.retry_title"), t("upload.retry_msg", { filename: item.filename }), [
+ { text: t("common.cancel"), style: "cancel" },
+ { text: t("common.retry"), onPress: () => onRetry(item) },
]);
}
@@ -339,10 +436,10 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"}
- accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
+ accessibilityLabel={canRetry ? `${t("common.retry")} ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
>
- {uploadIcons[item.status]}
+
{item.filename}
@@ -351,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
)}
{item.status === "done" && !item.serverStatus && (
- Queued for processing…
+ {t("upload.status_queued")}
)}
{item.status === "done" && item.serverStatus && (
{item.error}
{canRetry && (
- Tap to retry
+ {t("upload.tap_retry")}
)}
)}
@@ -397,7 +494,7 @@ const styles = StyleSheet.create({
cameraButton: { backgroundColor: "#1e40af" },
photoLibraryButton: { backgroundColor: "#7c3aed" },
fileButton: { backgroundColor: "#059669" },
- actionIcon: { fontSize: 28, marginBottom: 6 },
+ actionIcon: { marginBottom: 6 },
actionLabel: {
color: "#fff",
fontSize: 14,
@@ -409,7 +506,6 @@ const styles = StyleSheet.create({
alignItems: "center",
paddingTop: 60,
},
- emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
@@ -443,7 +539,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
- icon: { fontSize: 22, marginRight: 12 },
+ icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
diff --git a/mobile/src/screens/WelcomeScreen.tsx b/mobile/src/screens/WelcomeScreen.tsx
index f779456c..8f3b8127 100644
--- a/mobile/src/screens/WelcomeScreen.tsx
+++ b/mobile/src/screens/WelcomeScreen.tsx
@@ -6,6 +6,7 @@
*/
import { useRouter } from "expo-router";
+import * as Linking from "expo-linking";
import React from "react";
import {
Image,
@@ -16,27 +17,31 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
-
-const FEATURES: { icon: string; title: string; description: string }[] = [
- {
- icon: "🔍",
- title: "OCR & Text Extraction",
- description: "Convert scanned PDFs and images into fully searchable text automatically.",
- },
- {
- icon: "🤖",
- title: "AI Metadata Extraction",
- description: "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
- },
- {
- icon: "☁️",
- title: "Multi-Cloud Storage",
- description: "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more.",
- },
-];
+import { useLocale, t } from "../i18n";
export default function WelcomeScreen() {
const router = useRouter();
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
+
+ const features = [
+ {
+ icon: "🔍",
+ title: t("welcome.feature_ocr_title"),
+ description: t("welcome.feature_ocr_desc"),
+ },
+ {
+ icon: "🤖",
+ title: t("welcome.feature_ai_title"),
+ description: t("welcome.feature_ai_desc"),
+ },
+ {
+ icon: "☁️",
+ title: t("welcome.feature_cloud_title"),
+ description: t("welcome.feature_cloud_desc"),
+ },
+ ];
+
return (
DocuElevate
- Intelligent Document Processing
-
- Ingest documents, run OCR, extract metadata with AI, and route files
- to your cloud storage — all in one seamless pipeline.
-
+ {t("welcome.tagline")}
+ {t("welcome.description")}
{/* Feature highlights */}
- {FEATURES.map((feature) => (
+ {features.map((feature) => (
{feature.icon}
@@ -79,14 +81,42 @@ export default function WelcomeScreen() {
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={() => router.push("/(auth)/login")}
accessibilityRole="button"
- accessibilityLabel="Get started — connect to your DocuElevate server"
+ accessibilityLabel={t("welcome.get_started")}
>
- Get Started
+ {t("welcome.get_started")}
-
- Connect to your self-hosted or cloud DocuElevate server.
-
+ {t("welcome.hint")}
+
+ {/* Legal links – accessible pre-login for GDPR / Apple compliance */}
+
+ Linking.openURL("https://app.docuelevate.org/privacy")}
+ accessibilityRole="link"
+ accessibilityLabel={t("legal.privacy_policy")}
+ style={styles.legalLinkButton}
+ >
+ {t("legal.privacy_policy")}
+
+ ·
+ Linking.openURL("https://app.docuelevate.org/terms")}
+ accessibilityRole="link"
+ accessibilityLabel={t("legal.terms")}
+ style={styles.legalLinkButton}
+ >
+ {t("legal.terms")}
+
+ ·
+ Linking.openURL("https://app.docuelevate.org/imprint")}
+ accessibilityRole="link"
+ accessibilityLabel={t("legal.imprint")}
+ style={styles.legalLinkButton}
+ >
+ {t("legal.imprint")}
+
+
);
@@ -206,4 +236,26 @@ const styles = StyleSheet.create({
color: "rgba(255,255,255,0.55)",
textAlign: "center",
},
+ legalLinks: {
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ marginTop: 20,
+ flexWrap: "wrap",
+ },
+ legalLinkButton: {
+ minHeight: 44,
+ justifyContent: "center",
+ paddingHorizontal: 4,
+ },
+ legalLinkText: {
+ fontSize: 12,
+ color: "rgba(255,255,255,0.65)",
+ textDecorationLine: "underline",
+ },
+ legalSeparator: {
+ fontSize: 12,
+ color: "rgba(255,255,255,0.45)",
+ marginHorizontal: 4,
+ },
});
diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts
index 1853422a..27861f85 100644
--- a/mobile/src/services/api.ts
+++ b/mobile/src/services/api.ts
@@ -26,6 +26,7 @@ export interface WhoAmIResponse {
email: string | null;
avatar_url: string | null;
is_admin: boolean;
+ preferred_language: string | null;
}
export interface GenerateTokenResponse {
@@ -35,6 +36,14 @@ export interface GenerateTokenResponse {
created_at: string;
}
+export interface QRClaimResponse {
+ token: string;
+ token_id: number;
+ name: string;
+ owner_id: string;
+ created_at: string;
+}
+
export interface DeviceRegistration {
push_token: string;
device_name?: string;
@@ -58,10 +67,42 @@ export interface FileRecord {
}
export interface UploadResponse {
- task_id: string;
+ task_id?: string;
status: string;
original_filename: string;
stored_filename: string;
+ duplicate_of?: {
+ duplicate_type: string;
+ original_file_id: number;
+ original_filename: string;
+ message: string;
+ };
+}
+
+export interface ProcessingLog {
+ id: number;
+ task_id: string;
+ step_name: string;
+ status: string;
+ message: string;
+ timestamp: string;
+}
+
+export interface FileDetail {
+ file: {
+ id: number;
+ filehash: string;
+ original_filename: string;
+ local_filename: string;
+ file_size: number;
+ mime_type: string;
+ created_at: string;
+ };
+ processing_status: ProcessingStatus;
+ logs: ProcessingLog[];
+ files_on_disk: {
+ original: boolean;
+ };
}
// ---------------------------------------------------------------------------
@@ -157,11 +198,23 @@ class DocuElevateAPI {
});
}
+ /** Claim a QR login challenge and receive an API token. */
+ async claimQRChallenge(challengeToken: string, deviceName: string): Promise {
+ return this.request("POST", "/api/qr-auth/claim", {
+ body: { challenge_token: challengeToken, device_name: deviceName },
+ });
+ }
+
/** Return profile information for the authenticated user. */
async whoAmI(): Promise {
return this.request("GET", "/api/mobile/whoami");
}
+ /** Sync the user's preferred UI language to the server. */
+ async setServerLanguage(lang: string): Promise {
+ await this.request("POST", "/api/i18n/language", { body: { language: lang } });
+ }
+
// -------------------------------------------------------------------------
// Push notifications
// -------------------------------------------------------------------------
@@ -208,6 +261,11 @@ class DocuElevateAPI {
);
return data.processing_status;
}
+
+ /** Get full file details including processing logs. */
+ async getFileDetail(fileId: number): Promise {
+ return this.request("GET", `/api/files/${fileId}`);
+ }
}
export const api = new DocuElevateAPI();
diff --git a/mobile/src/utils/mimeTypes.ts b/mobile/src/utils/mimeTypes.ts
new file mode 100644
index 00000000..82f6de91
--- /dev/null
+++ b/mobile/src/utils/mimeTypes.ts
@@ -0,0 +1,44 @@
+/**
+ * Shared MIME type utilities for the DocuElevate mobile app.
+ *
+ * Used by the Linking handler in _layout.tsx, the catch-all +not-found.tsx,
+ * and any other code that needs to infer a MIME type from a file extension.
+ */
+
+/**
+ * Common MIME type mappings for file extensions.
+ * Used to infer the MIME type of files shared via the Share Sheet / "Open In…"
+ * so the server receives a correct Content-Type instead of application/octet-stream.
+ */
+export const EXT_TO_MIME: Record = {
+ pdf: "application/pdf",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ gif: "image/gif",
+ bmp: "image/bmp",
+ tiff: "image/tiff",
+ tif: "image/tiff",
+ webp: "image/webp",
+ heic: "image/heic",
+ heif: "image/heif",
+ txt: "text/plain",
+ csv: "text/csv",
+ doc: "application/msword",
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ xls: "application/vnd.ms-excel",
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ppt: "application/vnd.ms-powerpoint",
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ rtf: "application/rtf",
+ html: "text/html",
+ xml: "application/xml",
+ json: "application/json",
+ zip: "application/zip",
+};
+
+/** Infer MIME type from a filename's extension, or undefined if unknown. */
+export function mimeTypeFromFilename(filename: string): string | undefined {
+ const ext = filename.split(".").pop()?.toLowerCase();
+ return ext ? EXT_TO_MIME[ext] : undefined;
+}
diff --git a/mobile/src/utils/normalizeUri.ts b/mobile/src/utils/normalizeUri.ts
new file mode 100644
index 00000000..d32e7d7f
--- /dev/null
+++ b/mobile/src/utils/normalizeUri.ts
@@ -0,0 +1,20 @@
+/**
+ * Normalise a file URI for deduplication.
+ *
+ * - Decode percent-encoding (`%20` → ` `)
+ * - Collapse consecutive slashes after the scheme (`file:////` → `file:///`)
+ * - Strip trailing slashes
+ */
+export function normalizeFileUri(uri: string): string {
+ let norm: string;
+ try {
+ norm = decodeURIComponent(uri);
+ } catch {
+ norm = uri;
+ }
+ // Collapse multiple slashes after the scheme (e.g. file://// → file:///)
+ norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/");
+ // Strip trailing slash
+ norm = norm.replace(/\/+$/, "");
+ return norm;
+}
diff --git a/requirements.txt b/requirements.txt
index b4f35b22..a19105ae 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -60,3 +60,4 @@ sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
aiofiles>=24.1.0 # Asynchronous file I/O support
+segno>=1.6.0 # Pure-Python QR code generator (server-side rendering, no Pillow dependency)
diff --git a/scripts/check_alembic_migrations.py b/scripts/check_alembic_migrations.py
new file mode 100644
index 00000000..c399aef5
--- /dev/null
+++ b/scripts/check_alembic_migrations.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+"""Validate Alembic migration chain integrity.
+
+This script checks the migration files in ``migrations/versions/`` for
+common problems that arise when multiple feature branches add migrations
+in parallel and then get merged into *main*.
+
+Checks performed
+~~~~~~~~~~~~~~~~
+1. **Multiple heads** – more than one migration without a child means the
+ chain has diverged and a merge migration is needed.
+2. **Broken down-revision references** – a migration points to a
+ ``down_revision`` that does not exist.
+3. **Duplicate revision IDs** – two files declare the same ``revision``.
+4. **Revision / filename mismatch** – the ``revision`` variable inside a
+ file does not match the stem of the filename (minus the numeric
+ prefix).
+
+Exit codes
+~~~~~~~~~~
+* **0** – all checks passed.
+* **1** – one or more problems detected (details printed to *stderr*).
+* **2** – unexpected runtime error.
+
+Usage::
+
+ python scripts/check_alembic_migrations.py # from repo root
+ python scripts/check_alembic_migrations.py --verbose # extra detail
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import re
+import sys
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_REVISION_RE = re.compile(r'^revision\s*(?::\s*str\s*)?=\s*["\'](.+?)["\']', re.MULTILINE)
+_DOWN_REV_RE = re.compile(
+ r"^down_revision\s*(?::\s*Union\[str,\s*(?:None|tuple)\]\s*)?=\s*(.+)",
+ re.MULTILINE,
+)
+
+
+def _parse_down_revision(raw: str) -> list[str] | None:
+ """Parse a ``down_revision`` value into a list of parent revisions.
+
+ Returns ``None`` for the root migration (``down_revision = None``).
+ Returns a list with one or more strings otherwise. Tuples are
+ returned for merge migrations (e.g. ``("017_a", "017_b")``).
+ """
+ # Strip inline comments (e.g. ``None # type: ignore``)
+ raw = raw.strip()
+ if "#" in raw:
+ raw = raw[: raw.index("#")].strip()
+ try:
+ value = ast.literal_eval(raw)
+ except (ValueError, SyntaxError):
+ return [raw.strip("\"' ")]
+
+ if value is None:
+ return None
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, (tuple, list)):
+ return [str(v) for v in value]
+ return [str(value)]
+
+
+def _parse_migration(path: Path) -> dict | None:
+ """Extract ``revision`` and ``down_revision`` from a migration file."""
+ text = path.read_text(encoding="utf-8")
+
+ rev_match = _REVISION_RE.search(text)
+ down_match = _DOWN_REV_RE.search(text)
+
+ if not rev_match:
+ return None # not a valid migration file
+
+ revision = rev_match.group(1)
+ down_revision = _parse_down_revision(down_match.group(1)) if down_match else None
+
+ return {
+ "path": path,
+ "revision": revision,
+ "down_revision": down_revision,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Checks
+# ---------------------------------------------------------------------------
+
+
+def check_migrations(versions_dir: Path, *, verbose: bool = False) -> list[str]:
+ """Run all migration-chain checks and return a list of error messages."""
+ errors: list[str] = []
+
+ # Collect all migrations ------------------------------------------------
+ migrations: dict[str, dict] = {}
+ py_files = sorted(versions_dir.glob("*.py"))
+ if not py_files:
+ errors.append(f"No migration files found in {versions_dir}")
+ return errors
+
+ for path in py_files:
+ if path.name == "__init__.py":
+ continue
+ info = _parse_migration(path)
+ if info is None:
+ if verbose:
+ print(f" SKIP {path.name} (no revision found)", file=sys.stderr)
+ continue
+ rev = info["revision"]
+
+ # Check 1 – duplicate revision IDs
+ if rev in migrations:
+ errors.append(f"Duplicate revision '{rev}' in:\n - {migrations[rev]['path'].name}\n - {path.name}")
+ else:
+ migrations[rev] = info
+
+ if verbose:
+ parents = info["down_revision"] or ["(root)"]
+ print(f" {rev} ← {', '.join(parents)}", file=sys.stderr)
+
+ # Build child map -------------------------------------------------------
+ all_revisions = set(migrations.keys())
+ children: dict[str, list[str]] = {rev: [] for rev in all_revisions}
+
+ for rev, info in migrations.items():
+ parents = info["down_revision"]
+ if parents is None:
+ continue
+ for parent in parents:
+ # Check 2 – broken down_revision references
+ if parent not in all_revisions:
+ errors.append(
+ f"Broken chain: '{rev}' ({info['path'].name}) references "
+ f"down_revision '{parent}' which does not exist."
+ )
+ else:
+ children[parent].append(rev)
+
+ # Check 3 – multiple heads (revisions with no children) -----------------
+ heads = [rev for rev, kids in children.items() if not kids]
+ if len(heads) > 1:
+ head_details = "\n".join(f" - {h} ({migrations[h]['path'].name})" for h in sorted(heads))
+ errors.append(
+ f"Multiple migration heads detected ({len(heads)}). "
+ f"Create a merge migration to resolve:\n{head_details}\n\n"
+ f' Fix: alembic merge heads -m "merge_parallel_branches"'
+ )
+
+ # Check 4 – revision / filename consistency -----------------------------
+ for rev, info in migrations.items():
+ stem = info["path"].stem # e.g. "017_add_pipelines"
+ if rev != stem:
+ errors.append(
+ f"Filename mismatch: file '{info['path'].name}' declares "
+ f"revision='{rev}' but filename stem is '{stem}'."
+ )
+
+ return errors
+
+
+# ---------------------------------------------------------------------------
+# CLI entry-point
+# ---------------------------------------------------------------------------
+
+
+def main(argv: list[str] | None = None) -> int:
+ """CLI entry-point. Returns 0 on success, 1 on failure, 2 on error."""
+ parser = argparse.ArgumentParser(description="Check Alembic migration chain integrity.")
+ parser.add_argument(
+ "--versions-dir",
+ type=Path,
+ default=Path("migrations/versions"),
+ help="Path to Alembic versions directory (default: migrations/versions)",
+ )
+ parser.add_argument("--verbose", "-v", action="store_true", help="Print extra diagnostic info")
+ args = parser.parse_args(argv)
+
+ if not args.versions_dir.is_dir():
+ print(f"ERROR: versions directory not found: {args.versions_dir}", file=sys.stderr)
+ return 2
+
+ if args.verbose:
+ print("Scanning migrations…", file=sys.stderr)
+
+ errors = check_migrations(args.versions_dir, verbose=args.verbose)
+
+ if errors:
+ print(f"\n{'=' * 60}", file=sys.stderr)
+ print(f" Migration chain problems found: {len(errors)}", file=sys.stderr)
+ print(f"{'=' * 60}\n", file=sys.stderr)
+ for i, err in enumerate(errors, 1):
+ print(f" [{i}] {err}\n", file=sys.stderr)
+ return 1
+
+ print("✓ Migration chain is valid.", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/conftest.py b/tests/conftest.py
index f2655212..fd110d82 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -63,6 +63,7 @@ from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
AutomationHook,
+ ClassificationRuleModel,
ComplianceTemplate,
DocumentMetadata,
FileRecord,
@@ -116,6 +117,7 @@ def client(db_session) -> TestClient:
# Import the canonical get_db function
from app.database import get_db
+ from app.middleware.upload_rate_limit import require_upload_rate_limit
# Override the get_db dependency to use our test database
def override_get_db():
@@ -127,6 +129,14 @@ def client(db_session) -> TestClient:
# Override the single canonical get_db dependency
fastapi_app.dependency_overrides[get_db] = override_get_db
+ # Disable per-user upload rate limiting in tests so that upload-heavy
+ # test suites are not rejected with 429 Too Many Requests.
+ async def _no_rate_limit() -> None:
+ """No-op override: skip upload rate limiting during tests."""
+ return None
+
+ fastapi_app.dependency_overrides[require_upload_rate_limit] = _no_rate_limit
+
# Use base_url to satisfy TrustedHostMiddleware
with TestClient(fastapi_app, base_url="http://localhost") as test_client:
yield test_client
diff --git a/tests/test_allowed_types.py b/tests/test_allowed_types.py
index 23ad5f37..5802e5b2 100644
--- a/tests/test_allowed_types.py
+++ b/tests/test_allowed_types.py
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
".tif",
".webp",
".svg",
+ ".heic",
+ ".heif",
}
_html_extensions = {".html", ".htm"}
_markdown_extensions = {".md", ".markdown"}
diff --git a/tests/test_api_classification_rules.py b/tests/test_api_classification_rules.py
new file mode 100644
index 00000000..a519c6a4
--- /dev/null
+++ b/tests/test_api_classification_rules.py
@@ -0,0 +1,290 @@
+"""Tests for the classification rules API endpoints.
+
+Covers CRUD operations, validation, and access control for
+``/api/classification-rules``.
+"""
+
+import pytest
+
+from app.models import ClassificationRuleModel
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_rule(db_session, owner_id="anonymous", **overrides):
+ """Insert a ClassificationRuleModel and return it."""
+ defaults = {
+ "owner_id": owner_id,
+ "name": "test_rule",
+ "category": "invoice",
+ "rule_type": "filename_pattern",
+ "pattern": r"(?i)invoice",
+ "priority": 0,
+ "case_sensitive": False,
+ "enabled": True,
+ }
+ defaults.update(overrides)
+ rule = ClassificationRuleModel(**defaults)
+ db_session.add(rule)
+ db_session.commit()
+ db_session.refresh(rule)
+ return rule
+
+
+# ---------------------------------------------------------------------------
+# Categories & Rule Types endpoints
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestCategoriesEndpoint:
+ """Tests for GET /api/classification-rules/categories."""
+
+ def test_list_categories(self, client):
+ """Should return a dict of built-in categories."""
+ r = client.get("/api/classification-rules/categories")
+ assert r.status_code == 200
+ data = r.json()
+ assert isinstance(data, dict)
+ assert "invoice" in data
+ assert "contract" in data
+ assert "receipt" in data
+ assert "unknown" in data
+
+
+@pytest.mark.unit
+class TestRuleTypesEndpoint:
+ """Tests for GET /api/classification-rules/rule-types."""
+
+ def test_list_rule_types(self, client):
+ """Should return a list of valid rule types."""
+ r = client.get("/api/classification-rules/rule-types")
+ assert r.status_code == 200
+ data = r.json()
+ assert isinstance(data, list)
+ assert len(data) == 3
+ type_values = {item["type"] for item in data}
+ assert "filename_pattern" in type_values
+ assert "content_keyword" in type_values
+ assert "metadata_match" in type_values
+
+
+# ---------------------------------------------------------------------------
+# CRUD Operations
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestClassificationRuleCRUD:
+ """Full CRUD test-suite for classification rules."""
+
+ def test_list_rules_empty(self, client):
+ """List returns an empty array when no rules exist."""
+ r = client.get("/api/classification-rules/")
+ assert r.status_code == 200
+ assert r.json() == []
+
+ def test_create_rule(self, client):
+ """POST should create a new classification rule."""
+ r = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "My Invoice Rule",
+ "category": "invoice",
+ "rule_type": "filename_pattern",
+ "pattern": r"(?i)rechnung",
+ "priority": 10,
+ },
+ )
+ assert r.status_code == 201
+ data = r.json()
+ assert data["name"] == "My Invoice Rule"
+ assert data["category"] == "invoice"
+ assert data["rule_type"] == "filename_pattern"
+ assert data["priority"] == 10
+ assert data["enabled"] is True
+ assert data["id"] is not None
+
+ def test_create_rule_invalid_type_rejected(self, client):
+ """Creating a rule with an invalid rule_type should be rejected."""
+ r = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Bad Rule",
+ "category": "test",
+ "rule_type": "invalid_type",
+ "pattern": "test",
+ },
+ )
+ assert r.status_code == 400
+
+ def test_create_duplicate_name_rejected(self, client):
+ """Creating two rules with the same name should be rejected."""
+ payload = {
+ "name": "Dupe Rule",
+ "category": "invoice",
+ "rule_type": "filename_pattern",
+ "pattern": "test",
+ }
+ r1 = client.post("/api/classification-rules/", json=payload)
+ assert r1.status_code == 201
+ r2 = client.post("/api/classification-rules/", json=payload)
+ assert r2.status_code == 409
+
+ def test_get_rule(self, client):
+ """GET should return a specific rule by ID."""
+ create_resp = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Get Test Rule",
+ "category": "contract",
+ "rule_type": "content_keyword",
+ "pattern": "agreement|terms",
+ },
+ )
+ rule_id = create_resp.json()["id"]
+
+ r = client.get(f"/api/classification-rules/{rule_id}")
+ assert r.status_code == 200
+ assert r.json()["name"] == "Get Test Rule"
+ assert r.json()["category"] == "contract"
+
+ def test_get_nonexistent_rule(self, client):
+ """GET for a nonexistent rule should return 404."""
+ r = client.get("/api/classification-rules/99999")
+ assert r.status_code == 404
+
+ def test_update_rule(self, client):
+ """PUT should update an existing rule."""
+ create_resp = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Update Test",
+ "category": "receipt",
+ "rule_type": "filename_pattern",
+ "pattern": "receipt",
+ },
+ )
+ rule_id = create_resp.json()["id"]
+
+ r = client.put(
+ f"/api/classification-rules/{rule_id}",
+ json={"category": "invoice", "priority": 50},
+ )
+ assert r.status_code == 200
+ assert r.json()["category"] == "invoice"
+ assert r.json()["priority"] == 50
+ # Name should be unchanged
+ assert r.json()["name"] == "Update Test"
+
+ def test_update_nonexistent_rule(self, client):
+ """PUT for a nonexistent rule should return 404."""
+ r = client.put("/api/classification-rules/99999", json={"category": "test"})
+ assert r.status_code == 404
+
+ def test_update_invalid_rule_type_rejected(self, client):
+ """PUT with an invalid rule_type should be rejected."""
+ create_resp = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Invalid Update",
+ "category": "test",
+ "rule_type": "filename_pattern",
+ "pattern": "test",
+ },
+ )
+ rule_id = create_resp.json()["id"]
+
+ r = client.put(
+ f"/api/classification-rules/{rule_id}",
+ json={"rule_type": "bad_type"},
+ )
+ assert r.status_code == 400
+
+ def test_delete_rule(self, client):
+ """DELETE should remove the rule."""
+ create_resp = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Delete Test",
+ "category": "test",
+ "rule_type": "content_keyword",
+ "pattern": "test",
+ },
+ )
+ rule_id = create_resp.json()["id"]
+
+ r = client.delete(f"/api/classification-rules/{rule_id}")
+ assert r.status_code == 204
+
+ # Verify it's gone
+ r2 = client.get(f"/api/classification-rules/{rule_id}")
+ assert r2.status_code == 404
+
+ def test_delete_nonexistent_rule(self, client):
+ """DELETE for a nonexistent rule should return 404."""
+ r = client.delete("/api/classification-rules/99999")
+ assert r.status_code == 404
+
+ def test_list_rules_after_create(self, client):
+ """List should return created rules."""
+ client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "List Rule 1",
+ "category": "invoice",
+ "rule_type": "filename_pattern",
+ "pattern": "test1",
+ },
+ )
+ client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "List Rule 2",
+ "category": "contract",
+ "rule_type": "content_keyword",
+ "pattern": "test2",
+ },
+ )
+ r = client.get("/api/classification-rules/")
+ assert r.status_code == 200
+ assert len(r.json()) == 2
+
+ def test_create_rule_with_all_fields(self, client):
+ """Create a rule providing all optional fields."""
+ r = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Full Rule",
+ "category": "tax_document",
+ "rule_type": "metadata_match",
+ "pattern": "department=finance",
+ "priority": 100,
+ "case_sensitive": True,
+ "enabled": False,
+ },
+ )
+ assert r.status_code == 201
+ data = r.json()
+ assert data["case_sensitive"] is True
+ assert data["enabled"] is False
+ assert data["priority"] == 100
+
+ def test_create_rule_defaults(self, client):
+ """Create a rule with minimal fields to test defaults."""
+ r = client.post(
+ "/api/classification-rules/",
+ json={
+ "name": "Minimal Rule",
+ "category": "invoice",
+ "rule_type": "filename_pattern",
+ "pattern": "test",
+ },
+ )
+ assert r.status_code == 201
+ data = r.json()
+ assert data["priority"] == 0
+ assert data["case_sensitive"] is False
+ assert data["enabled"] is True
diff --git a/tests/test_api_dropbox.py b/tests/test_api_dropbox.py
index aacc57bb..71139d7b 100644
--- a/tests/test_api_dropbox.py
+++ b/tests/test_api_dropbox.py
@@ -416,3 +416,253 @@ class TestSaveDropboxSettings:
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
+
+
+@pytest.mark.unit
+class TestListDropboxFolders:
+ """Tests for list_dropbox_folders endpoint."""
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_success(self, mock_post, client):
+ """Test successful folder listing at root."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "entries": [
+ {".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"},
+ {".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"},
+ {".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"},
+ ],
+ "has_more": False,
+ }
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["folders"]) == 2
+ assert data["folders"][0]["name"] == "Documents"
+ assert data["folders"][1]["name"] == "Photos"
+ assert data["path"] == "/"
+ assert data["has_more"] is False
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_subfolder(self, mock_post, client):
+ """Test listing folders in a subfolder."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "entries": [
+ {".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"},
+ ],
+ "has_more": False,
+ }
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "test-token", "path": "/Documents"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["folders"]) == 1
+ assert data["folders"][0]["path"] == "/Documents/Invoices"
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_empty(self, mock_post, client):
+ """Test listing folders in an empty directory."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"entries": [], "has_more": False}
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "test-token", "path": "/EmptyFolder"},
+ )
+
+ assert response.status_code == 200
+ assert len(response.json()["folders"]) == 0
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_unauthorized(self, mock_post, client):
+ """Test listing folders with invalid token returns 401."""
+ mock_response = Mock()
+ mock_response.status_code = 401
+ mock_response.text = "Invalid access token"
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "bad-token", "path": ""},
+ )
+
+ assert response.status_code == 401
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_api_error(self, mock_post, client):
+ """Test listing folders when Dropbox API returns an error."""
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal server error"
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 502
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_root_path_normalization(self, mock_post, client):
+ """Test that '/' is normalized to empty string for Dropbox API."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"entries": [], "has_more": False}
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "test-token", "path": "/"},
+ )
+
+ assert response.status_code == 200
+ # Check the actual API call used empty string for root
+ call_args = mock_post.call_args
+ assert call_args[1]["json"]["path"] == ""
+
+ @patch("app.api.dropbox.requests.post")
+ def test_list_folders_sorted_alphabetically(self, mock_post, client):
+ """Test that folders are returned in alphabetical order."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "entries": [
+ {".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"},
+ {".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"},
+ {".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"},
+ ],
+ "has_more": False,
+ }
+ mock_post.return_value = mock_response
+
+ response = client.post(
+ "/api/dropbox/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 200
+ names = [f["name"] for f in response.json()["folders"]]
+ assert names == ["Alpha", "middle", "Zebra"]
+
+
+class TestBuildDropboxRedirectUri:
+ """Tests for the _build_dropbox_redirect_uri helper."""
+
+ def test_uses_public_base_url_when_set(self):
+ """When PUBLIC_BASE_URL is configured, redirect URI should use it."""
+ from unittest.mock import MagicMock
+
+ with patch("app.api.dropbox.settings") as mock_settings:
+ mock_settings.public_base_url = "https://myapp.example.com"
+ from app.api.dropbox import _build_dropbox_redirect_uri
+
+ mock_request = MagicMock()
+ result = _build_dropbox_redirect_uri(mock_request)
+
+ assert result == "https://myapp.example.com/dropbox-callback"
+
+ def test_uses_public_base_url_strips_trailing_slash(self):
+ """PUBLIC_BASE_URL with trailing slash should be handled correctly."""
+ from unittest.mock import MagicMock
+
+ with patch("app.api.dropbox.settings") as mock_settings:
+ mock_settings.public_base_url = "https://myapp.example.com/"
+ from app.api.dropbox import _build_dropbox_redirect_uri
+
+ mock_request = MagicMock()
+ result = _build_dropbox_redirect_uri(mock_request)
+
+ assert result == "https://myapp.example.com/dropbox-callback"
+
+ def test_falls_back_to_request_when_public_base_url_not_set(self):
+ """When PUBLIC_BASE_URL is not set, use request scheme and netloc."""
+ from unittest.mock import MagicMock
+
+ with patch("app.api.dropbox.settings") as mock_settings:
+ mock_settings.public_base_url = None
+ from app.api.dropbox import _build_dropbox_redirect_uri
+
+ mock_request = MagicMock()
+ mock_request.url.scheme = "https"
+ mock_request.url.netloc = "other.example.com"
+ result = _build_dropbox_redirect_uri(mock_request)
+
+ assert result == "https://other.example.com/dropbox-callback"
+
+
+@pytest.mark.unit
+class TestGlobalAuthorizeUrl:
+ """Tests for GET /api/dropbox/global-authorize-url endpoint."""
+
+ @patch("app.api.dropbox.settings")
+ def test_returns_authorize_url(self, mock_settings, client):
+ """Test that a valid authorize URL is returned when global creds are configured."""
+ mock_settings.dropbox_allow_global_credentials_for_integrations = True
+ mock_settings.dropbox_app_key = "test-app-key"
+ mock_settings.dropbox_app_secret = "test-app-secret"
+ mock_settings.public_base_url = "https://example.com"
+
+ response = client.get("/api/dropbox/global-authorize-url")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "authorize_url" in data
+ assert "https://www.dropbox.com/oauth2/authorize" in data["authorize_url"]
+ assert "client_id=test-app-key" in data["authorize_url"]
+ # redirect_uri should be URL-encoded
+ assert "redirect_uri=" in data["authorize_url"]
+ assert "https%3A%2F%2Fexample.com%2Fdropbox-callback" in data["authorize_url"]
+
+ @patch("app.api.dropbox.settings")
+ def test_returns_403_when_global_creds_disabled(self, mock_settings, client):
+ """Test 403 when global credentials for integrations are disabled."""
+ mock_settings.dropbox_allow_global_credentials_for_integrations = False
+ mock_settings.dropbox_app_key = "test-app-key"
+ mock_settings.dropbox_app_secret = "test-app-secret"
+
+ response = client.get("/api/dropbox/global-authorize-url")
+
+ assert response.status_code == 403
+
+ @patch("app.api.dropbox.settings")
+ def test_returns_503_when_creds_not_configured(self, mock_settings, client):
+ """Test 503 when global Dropbox credentials are not configured."""
+ mock_settings.dropbox_allow_global_credentials_for_integrations = True
+ mock_settings.dropbox_app_key = None
+ mock_settings.dropbox_app_secret = None
+
+ response = client.get("/api/dropbox/global-authorize-url")
+
+ assert response.status_code == 503
+
+ @patch("app.api.dropbox.settings")
+ def test_redirect_uri_uses_public_base_url(self, mock_settings, client):
+ """Redirect URI in authorize URL must use PUBLIC_BASE_URL when configured."""
+ mock_settings.dropbox_allow_global_credentials_for_integrations = True
+ mock_settings.dropbox_app_key = "my-key"
+ mock_settings.dropbox_app_secret = "my-secret"
+ mock_settings.public_base_url = "https://prod.example.com"
+
+ response = client.get("/api/dropbox/global-authorize-url")
+
+ assert response.status_code == 200
+ authorize_url = response.json()["authorize_url"]
+ # The redirect_uri must be URL-encoded and contain the public base URL
+ assert "https%3A%2F%2Fprod.example.com%2Fdropbox-callback" in authorize_url
diff --git a/tests/test_api_integrations.py b/tests/test_api_integrations.py
index 90d1c1f3..45336b2d 100644
--- a/tests/test_api_integrations.py
+++ b/tests/test_api_integrations.py
@@ -887,9 +887,9 @@ class TestConnectionTestEndpoint:
def test_test_unsupported_type(self, int_client):
"""Unsupported integration types return a helpful non-error message."""
payload = {
- "integration_type": "DROPBOX",
+ "integration_type": "FTP",
"config": {},
- "credentials": {"token": "abc"},
+ "credentials": {"username": "user", "password": "pass"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
@@ -897,6 +897,83 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "not yet supported" in data["message"]
+ def test_test_dropbox_missing_refresh_token(self, int_client):
+ """Dropbox test with missing refresh_token returns failure."""
+ payload = {
+ "integration_type": "DROPBOX",
+ "config": {},
+ "credentials": {"app_key": "key", "app_secret": "secret"},
+ }
+ resp = int_client.post("/api/integrations/test", json=payload)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["success"] is False
+ assert "refresh_token" in data["message"].lower()
+
+ def test_test_dropbox_missing_app_key(self, int_client):
+ """Dropbox test with missing app_key/app_secret returns failure."""
+ payload = {
+ "integration_type": "DROPBOX",
+ "config": {},
+ "credentials": {"refresh_token": "rtoken"},
+ }
+ resp = int_client.post("/api/integrations/test", json=payload)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["success"] is False
+ assert "app_key" in data["message"].lower()
+
+ def test_test_dropbox_invalid_credentials(self, int_client):
+ """Dropbox test with bad credentials returns an auth failure."""
+ from unittest.mock import MagicMock, patch
+
+ import dropbox.exceptions as dbx_exc
+
+ with patch("app.api.integrations.dbx_lib") as mock_dbx:
+ mock_instance = MagicMock()
+ mock_dbx.Dropbox.return_value = mock_instance
+ mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError("req_id", MagicMock())
+ payload = {
+ "integration_type": "DROPBOX",
+ "config": {},
+ "credentials": {
+ "app_key": "bad_key",
+ "app_secret": "bad_secret",
+ "refresh_token": "bad_token",
+ },
+ }
+ resp = int_client.post("/api/integrations/test", json=payload)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["success"] is False
+ assert "authentication failed" in data["message"].lower()
+
+ def test_test_dropbox_success(self, int_client):
+ """Dropbox test with valid (mocked) credentials returns success."""
+ from unittest.mock import MagicMock, patch
+
+ with patch("app.api.integrations.dbx_lib") as mock_dbx:
+ mock_instance = MagicMock()
+ mock_dbx.Dropbox.return_value = mock_instance
+ mock_account = MagicMock()
+ mock_account.name.display_name = "Test User"
+ mock_instance.users_get_current_account.return_value = mock_account
+
+ payload = {
+ "integration_type": "DROPBOX",
+ "config": {},
+ "credentials": {
+ "app_key": "valid_key",
+ "app_secret": "valid_secret",
+ "refresh_token": "valid_token",
+ },
+ }
+ resp = int_client.post("/api/integrations/test", json=payload)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["success"] is True
+ assert "dropbox connection successful" in data["message"].lower()
+
def test_test_invalid_type_returns_400(self, int_client):
"""Invalid integration_type returns 400."""
payload = {
diff --git a/tests/test_api_mobile.py b/tests/test_api_mobile.py
index 54b08967..5fcd7229 100644
--- a/tests/test_api_mobile.py
+++ b/tests/test_api_mobile.py
@@ -329,7 +329,7 @@ class TestDeactivateDevice:
"""Tests for DELETE /api/mobile/devices/{device_id}."""
def test_deactivate_own_device(self, mob_engine, mob_session):
- """Deactivating a device sets is_active to False."""
+ """Deactivating an active device sets is_active to False (soft-delete, returns 200)."""
from app.main import app
device = MobileDevice(
@@ -346,7 +346,8 @@ class TestDeactivateDevice:
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
- assert resp.status_code == 204
+ assert resp.status_code == 200
+ assert resp.json()["detail"] == "Device deactivated"
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
@@ -355,6 +356,33 @@ class TestDeactivateDevice:
finally:
_cleanup(app)
+ def test_delete_inactive_device(self, mob_engine, mob_session):
+ """Deleting an already-inactive device permanently removes it (hard-delete, returns 200)."""
+ from app.main import app
+
+ device = MobileDevice(
+ owner_id=_OWNER,
+ push_token=_EXPO_TOKEN,
+ platform="ios",
+ is_active=False,
+ )
+ mob_session.add(device)
+ mob_session.commit()
+ mob_session.refresh(device)
+ device_id = device.id
+
+ client = _make_client(mob_engine)
+ try:
+ resp = client.delete(f"/api/mobile/devices/{device_id}")
+ assert resp.status_code == 200
+ assert resp.json()["detail"] == "Device deleted"
+
+ mob_session.expire_all()
+ deleted = mob_session.get(MobileDevice, device_id)
+ assert deleted is None
+ finally:
+ _cleanup(app)
+
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
"""Attempting to deactivate another user's device returns 404."""
from app.main import app
@@ -439,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
+ assert data["preferred_language"] is None # not set yet
+ finally:
+ _cleanup(app)
+
+ def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
+ """preferred_language from UserProfile is included in the whoami response."""
+ from app.main import app
+ from app.models import UserProfile
+
+ profile = UserProfile(
+ user_id=_OWNER,
+ display_name="Bob Test",
+ preferred_language="de",
+ )
+ mob_session.add(profile)
+ mob_session.commit()
+
+ client = _make_client(mob_engine)
+ try:
+ resp = client.get("/api/mobile/whoami")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["preferred_language"] == "de"
+ finally:
+ _cleanup(app)
+
+ def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
+ """preferred_language is null when no UserProfile exists."""
+ from app.main import app
+
+ client = _make_client(mob_engine)
+ try:
+ resp = client.get("/api/mobile/whoami")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["preferred_language"] is None
finally:
_cleanup(app)
diff --git a/tests/test_api_onedrive_comprehensive.py b/tests/test_api_onedrive_comprehensive.py
index 1019031b..f46fb397 100644
--- a/tests/test_api_onedrive_comprehensive.py
+++ b/tests/test_api_onedrive_comprehensive.py
@@ -695,3 +695,182 @@ class TestOneDriveIntegration:
# Verify env format is present (exact values may vary)
assert "env_format" in config_data
+
+
+@pytest.mark.unit
+class TestListOneDriveFolders:
+ """Tests for list_onedrive_folders endpoint."""
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_success(self, mock_get, client):
+ """Test successful folder listing at root."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "value": [
+ {
+ "name": "Documents",
+ "id": "id:1",
+ "folder": {"childCount": 3},
+ "parentReference": {"path": "/drive/root:"},
+ },
+ {
+ "name": "Pictures",
+ "id": "id:2",
+ "folder": {"childCount": 10},
+ "parentReference": {"path": "/drive/root:"},
+ },
+ ],
+ }
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["folders"]) == 2
+ assert data["folders"][0]["name"] == "Documents"
+ assert data["folders"][0]["path"] == "/Documents"
+ assert data["folders"][1]["name"] == "Pictures"
+ assert data["path"] == "/"
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_subfolder(self, mock_get, client):
+ """Test listing folders in a subfolder."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "value": [
+ {
+ "name": "Invoices",
+ "id": "id:3",
+ "folder": {"childCount": 0},
+ "parentReference": {"path": "/drive/root:/Documents"},
+ },
+ ],
+ }
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "test-token", "path": "Documents"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["folders"]) == 1
+ assert data["folders"][0]["path"] == "/Documents/Invoices"
+ assert data["path"] == "/Documents"
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_empty(self, mock_get, client):
+ """Test listing folders in an empty directory."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"value": []}
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "test-token", "path": "EmptyFolder"},
+ )
+
+ assert response.status_code == 200
+ assert len(response.json()["folders"]) == 0
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_unauthorized(self, mock_get, client):
+ """Test listing folders with invalid token returns 401."""
+ mock_response = Mock()
+ mock_response.status_code = 401
+ mock_response.text = "Invalid access token"
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "bad-token", "path": ""},
+ )
+
+ assert response.status_code == 401
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_api_error(self, mock_get, client):
+ """Test listing folders when Graph API returns an error."""
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal server error"
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 502
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_sorted_alphabetically(self, mock_get, client):
+ """Test that folders are returned in alphabetical order."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "value": [
+ {
+ "name": "Zebra",
+ "id": "id:1",
+ "folder": {"childCount": 0},
+ "parentReference": {"path": "/drive/root:"},
+ },
+ {
+ "name": "Alpha",
+ "id": "id:2",
+ "folder": {"childCount": 0},
+ "parentReference": {"path": "/drive/root:"},
+ },
+ {
+ "name": "middle",
+ "id": "id:3",
+ "folder": {"childCount": 0},
+ "parentReference": {"path": "/drive/root:"},
+ },
+ ],
+ }
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 200
+ names = [f["name"] for f in response.json()["folders"]]
+ assert names == ["Alpha", "middle", "Zebra"]
+
+ @patch("app.api.onedrive.requests.get")
+ def test_list_folders_root_drive_parent(self, mock_get, client):
+ """Test folder path construction when parentReference.path is /drive/root."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "value": [
+ {
+ "name": "TopLevel",
+ "id": "id:1",
+ "folder": {"childCount": 0},
+ "parentReference": {"path": "/drive/root"},
+ },
+ ],
+ }
+ mock_get.return_value = mock_response
+
+ response = client.post(
+ "/api/onedrive/list-folders",
+ data={"access_token": "test-token", "path": ""},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["folders"][0]["path"] == "/TopLevel"
diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py
index dabf2dd8..2a2b124a 100644
--- a/tests/test_api_tokens.py
+++ b/tests/test_api_tokens.py
@@ -314,8 +314,8 @@ class TestTokenRevoke:
_cleanup(app)
@pytest.mark.unit
- def test_revoke_already_revoked_token(self, tok_engine):
- """Revoking an already-revoked token should return 400."""
+ def test_delete_already_revoked_token(self, tok_engine):
+ """Deleting an already-revoked token should permanently remove it (hard-delete, 200)."""
from app.main import app
client = _make_client(tok_engine)
@@ -324,9 +324,15 @@ class TestTokenRevoke:
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
+ # Second DELETE should hard-delete the revoked token.
resp = client.delete(f"/api/api-tokens/{token_id}")
- assert resp.status_code == 400
- assert resp.json()["detail"] == "Token is already revoked"
+ assert resp.status_code == 200
+ assert resp.json()["detail"] == "Token deleted"
+
+ # Token must no longer appear in the list.
+ list_resp = client.get("/api/api-tokens/")
+ ids = [t["id"] for t in list_resp.json()]
+ assert token_id not in ids
finally:
_cleanup(app)
@@ -677,3 +683,216 @@ class TestTokenUtils:
token = "de_test_token_value"
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
assert hash_token(token) == expected_hash
+
+
+# ---------------------------------------------------------------------------
+# Tests – Token reactivation
+# ---------------------------------------------------------------------------
+
+
+class TestTokenReactivate:
+ """Tests for POST /api/api-tokens/{id}/reactivate."""
+
+ @pytest.mark.unit
+ def test_reactivate_revoked_token(self, tok_engine):
+ """Reactivating a revoked token should set is_active=True and clear revoked_at."""
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"})
+ token_id = create_resp.json()["id"]
+ client.delete(f"/api/api-tokens/{token_id}")
+
+ resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["is_active"] is True
+ assert data["revoked_at"] is None
+ finally:
+ _cleanup(app)
+
+ @pytest.mark.unit
+ def test_reactivate_active_token_returns_400(self, tok_engine):
+ """Reactivating an already-active token should return 400."""
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"})
+ token_id = create_resp.json()["id"]
+
+ resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
+ assert resp.status_code == 400
+ assert resp.json()["detail"] == "Token is already active"
+ finally:
+ _cleanup(app)
+
+ @pytest.mark.unit
+ def test_reactivate_nonexistent_token(self, tok_engine):
+ """Reactivating a non-existent token should return 404."""
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ resp = client.post("/api/api-tokens/99999/reactivate")
+ assert resp.status_code == 404
+ finally:
+ _cleanup(app)
+
+ @pytest.mark.unit
+ def test_reactivate_other_users_token(self, tok_engine):
+ """A user cannot reactivate another user's token."""
+ from app.main import app
+
+ client_a = _make_client(tok_engine, _OWNER)
+ try:
+ create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"})
+ token_id = create_resp.json()["id"]
+ client_a.delete(f"/api/api-tokens/{token_id}")
+ finally:
+ _cleanup(app)
+
+ client_b = _make_client(tok_engine, _OTHER_OWNER)
+ try:
+ resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate")
+ assert resp.status_code == 404
+ finally:
+ _cleanup(app)
+
+
+# ---------------------------------------------------------------------------
+# Tests – Token lifetime (expires_at)
+# ---------------------------------------------------------------------------
+
+
+class TestTokenExpiry:
+ """Tests for token creation with optional lifetime and expiry enforcement."""
+
+ @pytest.mark.unit
+ def test_create_token_without_expiry(self, tok_engine):
+ """Creating a token without expires_in_days should leave expires_at as None."""
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ resp = client.post("/api/api-tokens/", json={"name": "No Expiry"})
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["expires_at"] is None
+ finally:
+ _cleanup(app)
+
+ @pytest.mark.unit
+ def test_create_token_with_expiry(self, tok_engine, tok_session):
+ """Creating a token with expires_in_days should set expires_at in the future."""
+ from datetime import datetime, timezone
+
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30})
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["expires_at"] is not None
+ # Parse the returned datetime; handle both tz-aware and tz-naive serialisations
+ expires_str = data["expires_at"].replace("Z", "+00:00")
+ expires_at = datetime.fromisoformat(expires_str)
+ if expires_at.tzinfo is None:
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
+ now = datetime.now(timezone.utc)
+ delta_days = (expires_at - now).days
+ assert 28 <= delta_days <= 30
+ finally:
+ _cleanup(app)
+
+ @pytest.mark.unit
+ def test_expired_token_not_resolved(self, tok_engine, tok_session):
+ """A token past its expires_at should not authenticate."""
+ from datetime import datetime, timedelta, timezone
+ from unittest.mock import MagicMock
+
+ from app.api.api_tokens import generate_api_token, hash_token
+ from app.auth import _resolve_bearer_user
+
+ plaintext = generate_api_token()
+ token_hash = hash_token(plaintext)
+
+ db_token = ApiToken(
+ owner_id=_OWNER,
+ name="Expired Token",
+ token_hash=token_hash,
+ token_prefix=plaintext[:12],
+ is_active=True,
+ expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday
+ )
+ tok_session.add(db_token)
+ tok_session.commit()
+
+ mock_request = MagicMock()
+ mock_request.headers = {"authorization": f"Bearer {plaintext}"}
+ mock_request.client.host = "127.0.0.1"
+
+ user = _resolve_bearer_user(mock_request, tok_session)
+ assert user is None
+
+ @pytest.mark.unit
+ def test_non_expired_token_resolves(self, tok_engine, tok_session):
+ """A token before its expires_at should authenticate normally."""
+ from datetime import datetime, timedelta, timezone
+ from unittest.mock import MagicMock
+
+ from app.api.api_tokens import generate_api_token, hash_token
+ from app.auth import _resolve_bearer_user
+
+ plaintext = generate_api_token()
+ token_hash = hash_token(plaintext)
+
+ db_token = ApiToken(
+ owner_id=_OWNER,
+ name="Valid Token",
+ token_hash=token_hash,
+ token_prefix=plaintext[:12],
+ is_active=True,
+ expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days
+ )
+ tok_session.add(db_token)
+ tok_session.commit()
+
+ mock_request = MagicMock()
+ mock_request.headers = {"authorization": f"Bearer {plaintext}"}
+ mock_request.client.host = "127.0.0.1"
+
+ user = _resolve_bearer_user(mock_request, tok_session)
+ assert user is not None
+ assert user["preferred_username"] == _OWNER
+
+ @pytest.mark.unit
+ def test_create_token_expires_in_days_zero_rejected(self, tok_engine):
+ """expires_in_days=0 should be rejected with 422 (ge=1)."""
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0})
+ assert resp.status_code == 422
+ finally:
+ _cleanup(app)
+
+ @pytest.mark.unit
+ def test_expires_at_included_in_list_response(self, tok_engine):
+ """List endpoint should include expires_at field."""
+ from app.main import app
+
+ client = _make_client(tok_engine)
+ try:
+ client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7})
+ resp = client.get("/api/api-tokens/")
+ assert resp.status_code == 200
+ tokens = resp.json()
+ assert len(tokens) == 1
+ assert "expires_at" in tokens[0]
+ assert tokens[0]["expires_at"] is not None
+ finally:
+ _cleanup(app)
diff --git a/tests/test_check_alembic_migrations.py b/tests/test_check_alembic_migrations.py
new file mode 100644
index 00000000..252d648b
--- /dev/null
+++ b/tests/test_check_alembic_migrations.py
@@ -0,0 +1,199 @@
+"""Tests for scripts/check_alembic_migrations.py."""
+
+# The script lives outside of the ``app`` package, so we import it by path.
+import importlib.util
+import textwrap
+from pathlib import Path
+
+import pytest
+
+_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_alembic_migrations.py"
+_spec = importlib.util.spec_from_file_location("check_alembic_migrations", _SCRIPT)
+assert _spec and _spec.loader
+_mod = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_mod) # type: ignore[union-attr]
+
+check_migrations = _mod.check_migrations
+main = _mod.main
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+def _write_migration(
+ directory: Path, filename: str, revision: str, down_revision: str | tuple[str, ...] | None
+) -> Path:
+ """Helper to create a minimal migration file."""
+ if down_revision is None:
+ down_rev_str = "None"
+ elif isinstance(down_revision, tuple):
+ down_rev_str = repr(down_revision)
+ else:
+ down_rev_str = f'"{down_revision}"'
+
+ content = textwrap.dedent(f'''\
+ """Test migration."""
+ from typing import Union
+ revision: str = "{revision}"
+ down_revision: Union[str, None] = {down_rev_str}
+ depends_on: Union[str, None] = None
+ def upgrade() -> None:
+ pass
+ def downgrade() -> None:
+ pass
+ ''')
+ path = directory / filename
+ path.write_text(content)
+ return path
+
+
+@pytest.fixture
+def versions_dir(tmp_path: Path) -> Path:
+ """Return a temporary versions directory."""
+ d = tmp_path / "versions"
+ d.mkdir()
+ return d
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestCheckMigrations:
+ """Tests for the check_migrations function."""
+
+ def test_valid_linear_chain(self, versions_dir: Path) -> None:
+ """A simple linear chain should pass with no errors."""
+ _write_migration(versions_dir, "001_initial.py", "001_initial", None)
+ _write_migration(versions_dir, "002_add_col.py", "002_add_col", "001_initial")
+ _write_migration(versions_dir, "003_add_table.py", "003_add_table", "002_add_col")
+
+ errors = check_migrations(versions_dir)
+ assert errors == []
+
+ def test_valid_merge_migration(self, versions_dir: Path) -> None:
+ """A chain with a merge point should pass."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ _write_migration(versions_dir, "002_a.py", "002_a", "001_base")
+ _write_migration(versions_dir, "002_b.py", "002_b", "001_base")
+
+ # Merge file with tuple down_revision
+ content = textwrap.dedent('''\
+ """Merge."""
+ from typing import Union
+ revision: str = "003_merge"
+ down_revision: Union[str, tuple] = ("002_a", "002_b")
+ depends_on: Union[str, None] = None
+ def upgrade() -> None:
+ pass
+ def downgrade() -> None:
+ pass
+ ''')
+ (versions_dir / "003_merge.py").write_text(content)
+
+ errors = check_migrations(versions_dir)
+ assert errors == []
+
+ def test_multiple_heads_detected(self, versions_dir: Path) -> None:
+ """Two unmerged branches should report multiple heads."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ _write_migration(versions_dir, "002_a.py", "002_a", "001_base")
+ _write_migration(versions_dir, "002_b.py", "002_b", "001_base")
+
+ errors = check_migrations(versions_dir)
+ assert len(errors) == 1
+ assert "Multiple migration heads" in errors[0]
+ assert "002_a" in errors[0]
+ assert "002_b" in errors[0]
+
+ def test_broken_down_revision(self, versions_dir: Path) -> None:
+ """A migration pointing to a non-existent parent should be flagged."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ _write_migration(versions_dir, "002_orphan.py", "002_orphan", "NONEXISTENT")
+
+ errors = check_migrations(versions_dir)
+ assert any("Broken chain" in e for e in errors)
+ assert any("NONEXISTENT" in e for e in errors)
+
+ def test_duplicate_revision(self, versions_dir: Path) -> None:
+ """Two files declaring the same revision should be flagged."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ _write_migration(versions_dir, "002_first.py", "002_dup", "001_base")
+ _write_migration(versions_dir, "002_second.py", "002_dup", "001_base")
+
+ errors = check_migrations(versions_dir)
+ assert any("Duplicate revision" in e for e in errors)
+
+ def test_filename_mismatch(self, versions_dir: Path) -> None:
+ """A file whose revision doesn't match its filename should be flagged."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ # filename stem is "002_wrong_name" but revision says "002_correct_name"
+ _write_migration(versions_dir, "002_wrong_name.py", "002_correct_name", "001_base")
+
+ errors = check_migrations(versions_dir)
+ assert any("Filename mismatch" in e for e in errors)
+
+ def test_empty_directory(self, versions_dir: Path) -> None:
+ """An empty versions directory should report an error."""
+ errors = check_migrations(versions_dir)
+ assert len(errors) == 1
+ assert "No migration files found" in errors[0]
+
+ def test_init_py_is_skipped(self, versions_dir: Path) -> None:
+ """__init__.py files should be ignored."""
+ (versions_dir / "__init__.py").write_text("")
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+
+ errors = check_migrations(versions_dir)
+ assert errors == []
+
+ def test_non_migration_file_skipped(self, versions_dir: Path) -> None:
+ """A .py file without a revision variable should be silently skipped."""
+ (versions_dir / "helper.py").write_text("# just a helper\nx = 1\n")
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+
+ errors = check_migrations(versions_dir)
+ assert errors == []
+
+
+@pytest.mark.unit
+class TestMainCLI:
+ """Tests for the CLI entry-point."""
+
+ def test_success_returns_zero(self, versions_dir: Path) -> None:
+ """Valid chain should exit 0."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ rc = main(["--versions-dir", str(versions_dir)])
+ assert rc == 0
+
+ def test_failure_returns_one(self, versions_dir: Path) -> None:
+ """Invalid chain should exit 1."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ _write_migration(versions_dir, "002_a.py", "002_a", "001_base")
+ _write_migration(versions_dir, "002_b.py", "002_b", "001_base")
+
+ rc = main(["--versions-dir", str(versions_dir)])
+ assert rc == 1
+
+ def test_missing_directory_returns_two(self, tmp_path: Path) -> None:
+ """Non-existent versions directory should exit 2."""
+ rc = main(["--versions-dir", str(tmp_path / "does_not_exist")])
+ assert rc == 2
+
+ def test_verbose_flag(self, versions_dir: Path) -> None:
+ """The --verbose flag should not crash."""
+ _write_migration(versions_dir, "001_base.py", "001_base", None)
+ rc = main(["--versions-dir", str(versions_dir), "--verbose"])
+ assert rc == 0
+
+ def test_real_migrations(self) -> None:
+ """Smoke test against the actual project migrations."""
+ real_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
+ if not real_dir.is_dir():
+ pytest.skip("migrations/versions directory not found in working tree")
+ rc = main(["--versions-dir", str(real_dir)])
+ assert rc == 0
diff --git a/tests/test_classification_rules.py b/tests/test_classification_rules.py
new file mode 100644
index 00000000..a4bacaec
--- /dev/null
+++ b/tests/test_classification_rules.py
@@ -0,0 +1,422 @@
+"""Tests for the rule-based document classification engine.
+
+Covers the classification engine logic in ``app/utils/classification_rules.py``:
+built-in rules, custom rules, confidence scoring, and edge cases.
+"""
+
+import pytest
+
+from app.utils.classification_rules import (
+ BUILTIN_CATEGORIES,
+ BUILTIN_RULES,
+ RULE_TYPE_CONTENT,
+ RULE_TYPE_FILENAME,
+ RULE_TYPE_METADATA,
+ ClassificationResult,
+ ClassificationRule,
+ MatchedRule,
+ classify_document,
+ db_rule_to_engine_rule,
+)
+
+# ---------------------------------------------------------------------------
+# Built-in categories & rules smoke tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestBuiltinCategories:
+ """Verify the pre-built categories and rules are sane."""
+
+ def test_builtin_categories_not_empty(self):
+ """There must be at least one built-in category."""
+ assert len(BUILTIN_CATEGORIES) > 0
+
+ def test_unknown_category_exists(self):
+ """The 'unknown' fallback category must be present."""
+ assert "unknown" in BUILTIN_CATEGORIES
+
+ def test_core_categories_present(self):
+ """Invoice, contract, and receipt categories must exist."""
+ for cat in ("invoice", "contract", "receipt"):
+ assert cat in BUILTIN_CATEGORIES, f"Missing built-in category: {cat}"
+
+ def test_builtin_rules_not_empty(self):
+ """There must be at least one built-in rule."""
+ assert len(BUILTIN_RULES) > 0
+
+ def test_all_builtin_rules_reference_valid_types(self):
+ """Every built-in rule must use a valid rule_type."""
+ valid_types = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA}
+ for rule in BUILTIN_RULES:
+ assert rule.rule_type in valid_types, f"Rule {rule.name!r} has invalid type {rule.rule_type!r}"
+
+
+# ---------------------------------------------------------------------------
+# ClassificationRule dataclass validation
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestClassificationRuleValidation:
+ """Test ClassificationRule dataclass validation."""
+
+ def test_valid_rule_types(self):
+ """Valid rule types should not raise."""
+ for rt in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA):
+ rule = ClassificationRule(name="test", category="test", rule_type=rt, pattern="test")
+ assert rule.rule_type == rt
+
+ def test_invalid_rule_type_raises(self):
+ """An invalid rule_type should raise ValueError."""
+ with pytest.raises(ValueError, match="Invalid rule_type"):
+ ClassificationRule(name="test", category="test", rule_type="invalid", pattern="test")
+
+
+# ---------------------------------------------------------------------------
+# Filename pattern matching
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestFilenamePatternMatching:
+ """Test classification via filename patterns."""
+
+ def test_invoice_filename(self):
+ """A filename containing 'invoice' should classify as invoice."""
+ result = classify_document(filename="2024-03-01_Invoice_Acme.pdf")
+ assert result.category == "invoice"
+ assert result.confidence > 0
+
+ def test_german_invoice_filename(self):
+ """A filename containing 'Rechnung' should classify as invoice."""
+ result = classify_document(filename="Rechnung_2024.pdf")
+ assert result.category == "invoice"
+ assert result.confidence > 0
+
+ def test_contract_filename(self):
+ """A filename containing 'contract' should classify as contract."""
+ result = classify_document(filename="Service_Contract_2024.pdf")
+ assert result.category == "contract"
+
+ def test_receipt_filename(self):
+ """A filename containing 'receipt' should classify as receipt."""
+ result = classify_document(filename="Payment_Receipt.pdf")
+ assert result.category == "receipt"
+
+ def test_unrecognized_filename(self):
+ """A generic filename with no keywords should return 'unknown'."""
+ result = classify_document(filename="document_12345.pdf")
+ assert result.category == "unknown"
+ assert result.confidence == 0
+
+ def test_empty_filename(self):
+ """An empty filename should not match any rule."""
+ result = classify_document(filename="")
+ assert result.category == "unknown"
+
+
+# ---------------------------------------------------------------------------
+# Content keyword matching
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestContentKeywordMatching:
+ """Test classification via content keywords."""
+
+ def test_invoice_content(self):
+ """Text containing 'invoice number' should classify as invoice."""
+ result = classify_document(text="Please pay the invoice number 12345. Amount due: $500")
+ assert result.category == "invoice"
+ assert result.confidence > 0
+
+ def test_contract_content(self):
+ """Text containing 'terms and conditions' should classify as contract."""
+ result = classify_document(text="The parties hereby agree to the following terms and conditions.")
+ assert result.category == "contract"
+
+ def test_receipt_content(self):
+ """Text containing 'payment received' should classify as receipt."""
+ result = classify_document(text="Thank you. Payment received for order #789.")
+ assert result.category == "receipt"
+
+ def test_bank_statement_content(self):
+ """Text containing 'account statement' should classify as bank_statement."""
+ result = classify_document(text="Monthly account statement. Opening balance: $1,000.")
+ assert result.category == "bank_statement"
+
+ def test_empty_text(self):
+ """Empty text should not match any content rule."""
+ result = classify_document(text="")
+ assert result.category == "unknown"
+
+ def test_case_insensitive_matching(self):
+ """Content matching should be case-insensitive by default."""
+ result = classify_document(text="INVOICE NUMBER 12345")
+ assert result.category == "invoice"
+
+
+# ---------------------------------------------------------------------------
+# Metadata matching
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestMetadataMatching:
+ """Test classification via metadata field matching."""
+
+ def test_document_type_invoice(self):
+ """metadata document_type=Invoice should classify as invoice."""
+ result = classify_document(metadata={"document_type": "Invoice"})
+ assert result.category == "invoice"
+ assert result.confidence >= 90
+
+ def test_document_type_contract(self):
+ """metadata document_type=Contract should classify as contract."""
+ result = classify_document(metadata={"document_type": "Contract"})
+ assert result.category == "contract"
+
+ def test_kommunikationsart_rechnung(self):
+ """German classification metadata should classify as invoice."""
+ result = classify_document(metadata={"kommunikationsart": "Rechnung"})
+ assert result.category == "invoice"
+
+ def test_no_metadata(self):
+ """None metadata should not match."""
+ result = classify_document(metadata=None)
+ assert result.category == "unknown"
+
+ def test_empty_metadata(self):
+ """Empty metadata dict should not match."""
+ result = classify_document(metadata={})
+ assert result.category == "unknown"
+
+ def test_metadata_case_insensitive(self):
+ """Metadata matching should be case-insensitive by default."""
+ result = classify_document(metadata={"document_type": "invoice"})
+ assert result.category == "invoice"
+
+
+# ---------------------------------------------------------------------------
+# Combined matching / confidence boosting
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestCombinedMatching:
+ """Test that multiple matching rules boost confidence."""
+
+ def test_filename_and_content_boost(self):
+ """Filename + content matching should produce higher confidence than either alone."""
+ filename_only = classify_document(filename="Invoice_2024.pdf")
+ combined = classify_document(filename="Invoice_2024.pdf", text="Invoice number: 12345. Amount due: $500.")
+ assert combined.confidence >= filename_only.confidence
+ assert len(combined.matched_rules) > len(filename_only.matched_rules)
+
+ def test_all_three_signals(self):
+ """Filename + content + metadata should produce highest confidence."""
+ result = classify_document(
+ filename="Invoice_Acme.pdf",
+ text="Invoice number: 12345. Amount due: $500.",
+ metadata={"document_type": "Invoice"},
+ )
+ assert result.category == "invoice"
+ assert result.confidence >= 90
+
+ def test_conflicting_signals_most_matches_wins(self):
+ """When filename says 'invoice' but content says 'contract', most matches wins."""
+ result = classify_document(
+ filename="Invoice.pdf",
+ text="The parties hereby agree to the following terms and conditions. "
+ "This agreement between Company A and Company B is effective immediately.",
+ )
+ # Content has more keyword matches for contract, but filename matches invoice.
+ # Either is acceptable as long as the result is deterministic.
+ assert result.category in ("invoice", "contract")
+
+
+# ---------------------------------------------------------------------------
+# Custom rules
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestCustomRules:
+ """Test user-defined custom classification rules."""
+
+ def test_custom_rule_matches(self):
+ """A custom filename rule should match when pattern hits."""
+ custom = [
+ ClassificationRule(
+ name="custom_hr_doc",
+ category="hr_document",
+ rule_type=RULE_TYPE_FILENAME,
+ pattern=r"(?i)employee|hiring|hr",
+ )
+ ]
+ result = classify_document(filename="Employee_Handbook.pdf", custom_rules=custom)
+ assert result.category == "hr_document"
+
+ def test_custom_content_rule(self):
+ """A custom content keyword rule should match."""
+ custom = [
+ ClassificationRule(
+ name="custom_medical",
+ category="medical",
+ rule_type=RULE_TYPE_CONTENT,
+ pattern="diagnosis|prescription|patient record",
+ )
+ ]
+ result = classify_document(text="Patient record for Jane Doe. Diagnosis: common cold.", custom_rules=custom)
+ assert result.category == "medical"
+
+ def test_custom_metadata_rule(self):
+ """A custom metadata rule should match."""
+ custom = [
+ ClassificationRule(
+ name="custom_legal",
+ category="legal",
+ rule_type=RULE_TYPE_METADATA,
+ pattern="department=legal",
+ )
+ ]
+ result = classify_document(metadata={"department": "legal"}, custom_rules=custom)
+ assert result.category == "legal"
+
+ def test_custom_rule_overrides_builtin(self):
+ """Custom rules with more matches should override built-in rules."""
+ custom = [
+ ClassificationRule(
+ name="custom_internal_invoice",
+ category="internal_invoice",
+ rule_type=RULE_TYPE_FILENAME,
+ pattern=r"(?i)invoice",
+ priority=100,
+ ),
+ ClassificationRule(
+ name="custom_internal_invoice_content",
+ category="internal_invoice",
+ rule_type=RULE_TYPE_CONTENT,
+ pattern="invoice number",
+ priority=100,
+ ),
+ ]
+ result = classify_document(
+ filename="Invoice_2024.pdf",
+ text="Invoice number: 12345",
+ custom_rules=custom,
+ )
+ # Both builtin and custom rules for "invoice" patterns match, but custom
+ # has "internal_invoice" as category. The category with more total matches wins.
+ assert result.category in ("invoice", "internal_invoice")
+ assert result.confidence > 0
+
+
+# ---------------------------------------------------------------------------
+# db_rule_to_engine_rule converter
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDbRuleConversion:
+ """Test the database model to engine rule converter."""
+
+ def test_converts_basic_fields(self):
+ """All basic fields should be mapped correctly."""
+
+ class FakeDbRule:
+ name = "test_rule"
+ category = "invoice"
+ rule_type = RULE_TYPE_FILENAME
+ pattern = r"(?i)invoice"
+ priority = 10
+ case_sensitive = True
+
+ engine_rule = db_rule_to_engine_rule(FakeDbRule())
+ assert engine_rule.name == "test_rule"
+ assert engine_rule.category == "invoice"
+ assert engine_rule.rule_type == RULE_TYPE_FILENAME
+ assert engine_rule.pattern == r"(?i)invoice"
+ assert engine_rule.priority == 10
+ assert engine_rule.case_sensitive is True
+
+ def test_defaults_case_sensitive_to_false(self):
+ """When case_sensitive is missing, default to False."""
+
+ class FakeDbRule:
+ name = "test"
+ category = "test"
+ rule_type = RULE_TYPE_CONTENT
+ pattern = "test"
+ priority = 0
+
+ engine_rule = db_rule_to_engine_rule(FakeDbRule())
+ assert engine_rule.case_sensitive is False
+
+
+# ---------------------------------------------------------------------------
+# ClassificationResult
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestClassificationResult:
+ """Test the ClassificationResult dataclass."""
+
+ def test_default_matched_rules(self):
+ """matched_rules should default to an empty list."""
+ result = ClassificationResult(category="test", confidence=50)
+ assert result.matched_rules == []
+
+ def test_with_matched_rules(self):
+ """matched_rules should be populated when provided."""
+ match = MatchedRule(rule_name="test", rule_type=RULE_TYPE_FILENAME, category="invoice", confidence=60)
+ result = ClassificationResult(category="invoice", confidence=60, matched_rules=[match])
+ assert len(result.matched_rules) == 1
+ assert result.matched_rules[0].rule_name == "test"
+
+
+# ---------------------------------------------------------------------------
+# Edge cases
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestEdgeCases:
+ """Test edge cases in the classification engine."""
+
+ def test_no_inputs_at_all(self):
+ """No filename, text, or metadata should return 'unknown'."""
+ result = classify_document()
+ assert result.category == "unknown"
+ assert result.confidence == 0
+ assert result.matched_rules == []
+
+ def test_metadata_pattern_without_equals(self):
+ """A metadata pattern without '=' should not match."""
+ custom = [
+ ClassificationRule(
+ name="bad_pattern",
+ category="test",
+ rule_type=RULE_TYPE_METADATA,
+ pattern="no_equals_sign",
+ )
+ ]
+ result = classify_document(metadata={"no_equals_sign": "value"}, custom_rules=custom)
+ assert result.category == "unknown"
+
+ def test_confidence_capped_at_100(self):
+ """Confidence should never exceed 100."""
+ # Create many rules that all match to test the cap
+ custom = [
+ ClassificationRule(
+ name=f"flood_{i}",
+ category="flood",
+ rule_type=RULE_TYPE_CONTENT,
+ pattern="test keyword",
+ )
+ for i in range(20)
+ ]
+ result = classify_document(text="test keyword is here", custom_rules=custom)
+ assert result.confidence <= 100
diff --git a/tests/test_classify_document.py b/tests/test_classify_document.py
new file mode 100644
index 00000000..60fa2fe5
--- /dev/null
+++ b/tests/test_classify_document.py
@@ -0,0 +1,232 @@
+"""Tests for the classify_document Celery task.
+
+Covers the ``classify_document_task`` in ``app/tasks/classify_document.py``.
+"""
+
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from app.models import ClassificationRuleModel, FileRecord
+from app.tasks.classify_document import _load_custom_rules
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_file_record(db_session, **overrides):
+ """Insert a minimal FileRecord and return it."""
+ defaults = {
+ "owner_id": "test-user",
+ "filehash": "abc123",
+ "original_filename": "Invoice_2024.pdf",
+ "local_filename": "/tmp/test.pdf",
+ "file_size": 1024,
+ "mime_type": "application/pdf",
+ "ocr_text": "Invoice number: 12345. Amount due: $500.",
+ "ai_metadata": None,
+ }
+ defaults.update(overrides)
+ fr = FileRecord(**defaults)
+ db_session.add(fr)
+ db_session.commit()
+ db_session.refresh(fr)
+ return fr
+
+
+def _make_rule(db_session, **overrides):
+ """Insert a ClassificationRuleModel and return it."""
+ defaults = {
+ "owner_id": None,
+ "name": "test_rule",
+ "category": "test_category",
+ "rule_type": "filename_pattern",
+ "pattern": r"(?i)test",
+ "priority": 0,
+ "case_sensitive": False,
+ "enabled": True,
+ }
+ defaults.update(overrides)
+ rule = ClassificationRuleModel(**defaults)
+ db_session.add(rule)
+ db_session.commit()
+ db_session.refresh(rule)
+ return rule
+
+
+# ---------------------------------------------------------------------------
+# _load_custom_rules
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLoadCustomRules:
+ """Test the custom rule loading helper."""
+
+ @patch("app.tasks.classify_document.SessionLocal")
+ def test_loads_enabled_rules(self, mock_session_local):
+ """Should load enabled rules from the database."""
+ mock_rule = MagicMock()
+ mock_rule.name = "rule1"
+ mock_rule.category = "invoice"
+ mock_rule.rule_type = "filename_pattern"
+ mock_rule.pattern = r"(?i)invoice"
+ mock_rule.priority = 10
+ mock_rule.case_sensitive = False
+
+ mock_db = MagicMock()
+ mock_query = MagicMock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.all.return_value = [mock_rule]
+ mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
+ mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
+
+ rules = _load_custom_rules(owner_id="test-user")
+ assert len(rules) == 1
+ assert rules[0].name == "rule1"
+ assert rules[0].category == "invoice"
+
+
+# ---------------------------------------------------------------------------
+# classify_document_task (integration-style with mocked DB and Celery)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestClassifyDocumentTask:
+ """Test the Celery classify_document_task."""
+
+ @patch("app.tasks.classify_document.log_task_progress")
+ @patch("app.tasks.classify_document._load_custom_rules", return_value=[])
+ @patch("app.tasks.classify_document.SessionLocal")
+ def test_classify_invoice_file(self, mock_session_local, mock_load_rules, mock_log):
+ """Should classify a file with invoice filename and text as 'invoice'."""
+ mock_file = MagicMock(spec=FileRecord)
+ mock_file.id = 1
+ mock_file.original_filename = "Invoice_2024.pdf"
+ mock_file.ocr_text = "Invoice number: 12345. Amount due: $500."
+ mock_file.ai_metadata = None
+ mock_file.owner_id = "test-user"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_file
+ mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
+ mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.tasks.classify_document import classify_document_task
+
+ # Call the underlying function directly via .run(), bypassing Celery
+ result = classify_document_task.run(1, owner_id="test-user")
+
+ assert result["status"] == "success"
+ assert result["category"] == "invoice"
+ assert result["confidence"] > 0
+
+ # Verify ai_metadata was updated
+ assert mock_file.ai_metadata is not None
+ metadata = json.loads(mock_file.ai_metadata)
+ assert "classification" in metadata
+ assert metadata["classification"]["category"] == "invoice"
+
+ @patch("app.tasks.classify_document.log_task_progress")
+ @patch("app.tasks.classify_document.SessionLocal")
+ def test_classify_file_not_found(self, mock_session_local, mock_log):
+ """Should return error when file record is not found."""
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
+ mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.tasks.classify_document import classify_document_task
+
+ result = classify_document_task.run(99999)
+ assert result["status"] == "error"
+
+ @patch("app.tasks.classify_document.log_task_progress")
+ @patch("app.tasks.classify_document._load_custom_rules", return_value=[])
+ @patch("app.tasks.classify_document.SessionLocal")
+ def test_classify_preserves_existing_metadata(self, mock_session_local, mock_load_rules, mock_log):
+ """Should preserve existing ai_metadata fields and add classification."""
+ existing_meta = json.dumps({"document_type": "Invoice", "tags": ["finance"]})
+
+ mock_file = MagicMock(spec=FileRecord)
+ mock_file.id = 2
+ mock_file.original_filename = "doc.pdf"
+ mock_file.ocr_text = ""
+ mock_file.ai_metadata = existing_meta
+ mock_file.owner_id = "test-user"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_file
+ mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
+ mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.tasks.classify_document import classify_document_task
+
+ classify_document_task.run(2)
+
+ # Check that existing fields are preserved
+ metadata = json.loads(mock_file.ai_metadata)
+ assert metadata["tags"] == ["finance"]
+ assert metadata["document_type"] == "Invoice"
+ assert "classification" in metadata
+
+ @patch("app.tasks.classify_document.log_task_progress")
+ @patch("app.tasks.classify_document._load_custom_rules", return_value=[])
+ @patch("app.tasks.classify_document.SessionLocal")
+ def test_classify_sets_document_type_when_missing(self, mock_session_local, mock_load_rules, mock_log):
+ """Should set document_type from classification when not already present."""
+ mock_file = MagicMock(spec=FileRecord)
+ mock_file.id = 3
+ mock_file.original_filename = "Invoice_2024.pdf"
+ mock_file.ocr_text = "Invoice number: 12345"
+ mock_file.ai_metadata = json.dumps({"tags": ["test"]})
+ mock_file.owner_id = "test-user"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_file
+ mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
+ mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.tasks.classify_document import classify_document_task
+
+ classify_document_task.run(3)
+
+ metadata = json.loads(mock_file.ai_metadata)
+ assert metadata["document_type"] == "Invoice"
+
+ @patch("app.tasks.classify_document.log_task_progress")
+ @patch("app.tasks.classify_document._load_custom_rules", return_value=[])
+ @patch("app.tasks.classify_document.SessionLocal")
+ def test_classify_unknown_document(self, mock_session_local, mock_load_rules, mock_log):
+ """Should classify as 'unknown' when no rules match."""
+ mock_file = MagicMock(spec=FileRecord)
+ mock_file.id = 4
+ mock_file.original_filename = "random_file.pdf"
+ mock_file.ocr_text = "Lorem ipsum dolor sit amet."
+ mock_file.ai_metadata = None
+ mock_file.owner_id = "test-user"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_file
+ mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
+ mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
+
+ from app.tasks.classify_document import classify_document_task
+
+ result = classify_document_task.run(4)
+
+ assert result["category"] == "unknown"
+ assert result["confidence"] == 0
+
+ def test_classify_document_task_is_celery_task(self):
+ """Task should be registered as a Celery task."""
+ from app.tasks.classify_document import classify_document_task
+
+ assert hasattr(classify_document_task, "apply_async")
+ assert hasattr(classify_document_task, "delay")
+ assert callable(classify_document_task)
diff --git a/tests/test_coverage_uploads_notification.py b/tests/test_coverage_uploads_notification.py
index c59ada46..a255605c 100644
--- a/tests/test_coverage_uploads_notification.py
+++ b/tests/test_coverage_uploads_notification.py
@@ -665,6 +665,7 @@ def _all_should_upload_false():
"email",
"onedrive",
"s3",
+ "sharepoint",
"icloud",
]
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
@@ -694,6 +695,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
+ patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
):
@@ -806,6 +808,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
+ patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
@@ -866,6 +869,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
+ patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
diff --git a/tests/test_csrf.py b/tests/test_csrf.py
index a6041038..a80e6e8a 100644
--- a/tests/test_csrf.py
+++ b/tests/test_csrf.py
@@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch:
call_next.assert_called_once_with(request)
+ @pytest.mark.asyncio
+ async def test_qr_auth_claim_is_exempt(self):
+ """QR auth claim path is exempt from CSRF validation.
+
+ The mobile app calls this endpoint without a browser session and
+ therefore without a CSRF token. The cryptographically-random,
+ single-use challenge token provides equivalent protection.
+ """
+ middleware = self._make_middleware()
+ request = self._make_request(
+ method="POST",
+ path="/api/qr-auth/claim",
+ session={},
+ )
+ call_next = AsyncMock(return_value=MagicMock())
+
+ with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
+ result = await middleware.dispatch(request, call_next)
+
+ call_next.assert_called_once_with(request)
+
# ---------------------------------------------------------------------------
# Integration tests – via TestClient
@@ -362,6 +383,7 @@ class TestCSRFIntegration:
assert "PATCH" in CSRF_PROTECTED_METHODS
assert "GET" not in CSRF_PROTECTED_METHODS
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
+ assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS
def test_csrf_middleware_noop_when_auth_disabled(self):
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
diff --git a/tests/test_database.py b/tests/test_database.py
index 64e0e70e..3bd822f7 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -998,3 +998,49 @@ class TestAlembicUpgrade:
# Verify head is reachable
heads = script.get_heads()
assert len(heads) == 1 # Should be a single linear chain
+
+
+@pytest.mark.unit
+class TestEnginePoolConfiguration:
+ """Tests for database engine pool configuration (pool class and options)."""
+
+ def test_sqlite_engine_uses_null_pool(self):
+ """SQLite engines must use NullPool to prevent QueuePool exhaustion."""
+ from sqlalchemy.pool import NullPool
+
+ from app.database import engine
+
+ # The test environment uses SQLite, so NullPool should be in effect.
+ assert isinstance(engine.pool, NullPool)
+
+ def test_create_engine_sqlite_null_pool(self):
+ """Explicitly create a SQLite engine to confirm NullPool is applied."""
+ from sqlalchemy import create_engine
+ from sqlalchemy.pool import NullPool
+
+ test_engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=NullPool,
+ )
+ assert isinstance(test_engine.pool, NullPool)
+ test_engine.dispose()
+
+ def test_pool_settings_exist_in_config(self):
+ """Verify that pool tuning settings are exposed through config."""
+ from app.config import settings
+
+ assert hasattr(settings, "db_pool_size")
+ assert hasattr(settings, "db_max_overflow")
+ assert hasattr(settings, "db_pool_timeout")
+ assert hasattr(settings, "db_pool_recycle")
+
+ def test_pool_settings_have_sensible_defaults(self):
+ """Default pool settings should be larger than SQLAlchemy's built-in defaults."""
+ from app.config import settings
+
+ # SQLAlchemy defaults: pool_size=5, max_overflow=10
+ assert settings.db_pool_size >= 10
+ assert settings.db_max_overflow >= 20
+ assert settings.db_pool_timeout >= 30
+ assert settings.db_pool_recycle >= 1800
diff --git a/tests/test_devices_page.py b/tests/test_devices_page.py
new file mode 100644
index 00000000..106cba0c
--- /dev/null
+++ b/tests/test_devices_page.py
@@ -0,0 +1,201 @@
+"""Tests for the Devices page and mobile token filtering (app/api/api_tokens.py mobile endpoint).
+
+These tests validate:
+- ``GET /api/api-tokens/mobile`` returns only mobile tokens
+- ``GET /api/api-tokens/`` excludes mobile tokens
+- ``GET /devices`` renders the devices page
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.database import Base, get_db
+from app.models import ApiToken
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+_OWNER = "devices_user@example.com"
+_OTHER_OWNER = "other_devices@example.com"
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def dev_engine():
+ """In-memory SQLite engine."""
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ yield engine
+ Base.metadata.drop_all(bind=engine)
+
+
+@pytest.fixture()
+def dev_session(dev_engine):
+ """DB session scoped to one test."""
+ Session = sessionmaker(bind=dev_engine)
+ session = Session()
+ yield session
+ session.close()
+
+
+def _make_client(dev_engine, owner_id: str = _OWNER) -> TestClient:
+ """Return a TestClient with *owner_id* injected as the authenticated user."""
+ from app.api.api_tokens import _get_owner_id
+ from app.main import app
+
+ Session = sessionmaker(bind=dev_engine)
+
+ def _override_get_db():
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ def _override_owner():
+ return owner_id
+
+ app.dependency_overrides[get_db] = _override_get_db
+ app.dependency_overrides[_get_owner_id] = _override_owner
+
+ client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
+ return client
+
+
+def _cleanup(app):
+ """Remove dependency overrides after test."""
+ app.dependency_overrides.clear()
+
+
+def _seed_tokens(session, owner_id: str = _OWNER):
+ """Create a mix of regular and mobile tokens for testing."""
+ from app.api.api_tokens import generate_api_token, hash_token
+
+ tokens = []
+ # Regular API tokens
+ for name in ["CI Pipeline", "Webhook Upload"]:
+ pt = generate_api_token()
+ t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
+ session.add(t)
+ tokens.append(t)
+
+ # Mobile tokens (various naming patterns)
+ for name in [
+ "Mobile App – iPhone 15 Pro",
+ "Mobile App (QR) – Christian's iPad",
+ "Mobile App",
+ ]:
+ pt = generate_api_token()
+ t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
+ session.add(t)
+ tokens.append(t)
+
+ session.commit()
+ return tokens
+
+
+# ---------------------------------------------------------------------------
+# Tests – Mobile Token Filtering
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestMobileTokenFiltering:
+ """Tests for GET /api/api-tokens/mobile and filtering from GET /api/api-tokens/."""
+
+ def test_list_mobile_tokens_returns_only_mobile(self, dev_engine, dev_session):
+ """GET /api/api-tokens/mobile should only return tokens starting with 'Mobile App'."""
+ _seed_tokens(dev_session)
+ client = _make_client(dev_engine)
+ try:
+ res = client.get("/api/api-tokens/mobile")
+ assert res.status_code == 200
+ data = res.json()
+ assert len(data) == 3
+ for t in data:
+ assert t["name"].startswith("Mobile App")
+ finally:
+ _cleanup(client.app)
+
+ def test_list_regular_tokens_excludes_mobile(self, dev_engine, dev_session):
+ """GET /api/api-tokens/ should NOT return tokens starting with 'Mobile App'."""
+ _seed_tokens(dev_session)
+ client = _make_client(dev_engine)
+ try:
+ res = client.get("/api/api-tokens/")
+ assert res.status_code == 200
+ data = res.json()
+ assert len(data) == 2
+ for t in data:
+ assert not t["name"].startswith("Mobile App")
+ finally:
+ _cleanup(client.app)
+
+ def test_list_mobile_tokens_empty(self, dev_engine):
+ """GET /api/api-tokens/mobile returns [] when no mobile tokens exist."""
+ client = _make_client(dev_engine)
+ try:
+ res = client.get("/api/api-tokens/mobile")
+ assert res.status_code == 200
+ assert res.json() == []
+ finally:
+ _cleanup(client.app)
+
+ def test_list_mobile_tokens_isolation(self, dev_engine, dev_session):
+ """Mobile tokens for other users should not appear."""
+ _seed_tokens(dev_session, owner_id=_OTHER_OWNER)
+ client = _make_client(dev_engine, owner_id=_OWNER)
+ try:
+ res = client.get("/api/api-tokens/mobile")
+ assert res.status_code == 200
+ assert res.json() == []
+ finally:
+ _cleanup(client.app)
+
+ def test_mobile_token_revoke_via_api_tokens_endpoint(self, dev_engine, dev_session):
+ """Mobile tokens can still be revoked via DELETE /api/api-tokens/{id}."""
+ tokens = _seed_tokens(dev_session)
+ mobile_token = next(t for t in tokens if t.name.startswith("Mobile App"))
+ client = _make_client(dev_engine)
+ try:
+ res = client.delete(f"/api/api-tokens/{mobile_token.id}")
+ assert res.status_code == 200
+ # Verify it's gone from mobile list
+ res2 = client.get("/api/api-tokens/mobile")
+ active_names = [t["name"] for t in res2.json() if t["is_active"]]
+ assert mobile_token.name not in active_names
+ finally:
+ _cleanup(client.app)
+
+
+# ---------------------------------------------------------------------------
+# Tests – Devices Page View
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDevicesPageView:
+ """Tests for GET /devices page rendering."""
+
+ def test_devices_page_renders(self, dev_engine):
+ """GET /devices should return 200 with the devices template."""
+ from app.views.devices import router as _ # noqa: F401 – ensures route is registered
+
+ client = _make_client(dev_engine)
+ try:
+ res = client.get("/devices")
+ assert res.status_code == 200
+ assert "devices.heading" in res.text or "Mobile Devices" in res.text
+ finally:
+ _cleanup(client.app)
diff --git a/tests/test_diagnostic.py b/tests/test_diagnostic.py
index fa47acc1..8d3f917a 100644
--- a/tests/test_diagnostic.py
+++ b/tests/test_diagnostic.py
@@ -5,6 +5,86 @@ from unittest.mock import MagicMock, patch
import pytest
+@pytest.mark.unit
+class TestLivenessProbe:
+ """Tests for GET /api/diagnostic/healthz/live (unauthenticated)."""
+
+ def test_liveness_returns_200(self, client):
+ """Liveness probe always returns 200 OK."""
+ response = client.get("/api/diagnostic/healthz/live")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "ok"
+
+
+@pytest.mark.unit
+class TestReadinessProbe:
+ """Tests for GET /api/diagnostic/healthz/ready (unauthenticated)."""
+
+ def test_readiness_returns_200_when_all_ok(self, client):
+ """Readiness probe returns 200 when database and Redis are reachable."""
+ with (
+ patch("app.api.diagnostic.engine") as mock_engine,
+ patch("app.api.diagnostic.redis_lib") as mock_redis,
+ ):
+ mock_conn = MagicMock()
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_redis_inst = MagicMock()
+ mock_redis.from_url.return_value = mock_redis_inst
+
+ response = client.get("/api/diagnostic/healthz/ready")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "ready"
+ assert data["checks"]["database"]["status"] == "ok"
+
+ def test_readiness_returns_503_when_database_fails(self, client):
+ """Readiness probe returns 503 when database is unreachable."""
+ with (
+ patch("app.api.diagnostic.engine") as mock_engine,
+ patch("app.api.diagnostic.redis_lib") as mock_redis,
+ ):
+ mock_engine.connect.side_effect = Exception("DB unavailable")
+ mock_redis_inst = MagicMock()
+ mock_redis.from_url.return_value = mock_redis_inst
+
+ response = client.get("/api/diagnostic/healthz/ready")
+
+ assert response.status_code == 503
+ data = response.json()
+ assert data["status"] == "not_ready"
+ assert data["checks"]["database"]["status"] == "error"
+
+ def test_readiness_returns_200_when_redis_fails(self, client):
+ """Readiness remains 200 when only Redis is down (non-critical)."""
+ with (
+ patch("app.api.diagnostic.engine") as mock_engine,
+ patch("app.api.diagnostic.redis_lib") as mock_redis,
+ ):
+ mock_conn = MagicMock()
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_redis.from_url.return_value = MagicMock()
+ mock_redis.from_url.return_value.ping.side_effect = Exception("Connection refused")
+
+ response = client.get("/api/diagnostic/healthz/ready")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "ready"
+ assert data["checks"]["redis"]["status"] == "error"
+
+ def test_readiness_contains_checks_keys(self, client):
+ """Readiness response always contains database and redis checks."""
+ response = client.get("/api/diagnostic/healthz/ready")
+ data = response.json()
+ assert "checks" in data
+ assert "database" in data["checks"]
+ assert "redis" in data["checks"]
+
+
@pytest.mark.unit
class TestHealthEndpoint:
"""Tests for GET /api/diagnostic/health endpoint."""
diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py
index 45486168..987b1599 100644
--- a/tests/test_duplicates.py
+++ b/tests/test_duplicates.py
@@ -3,11 +3,12 @@
Covers:
- ``GET /api/duplicates`` — list all exact-duplicate groups
- ``GET /api/files/{id}/duplicates`` — per-file exact + near-duplicate info
-- ``POST /api/ui-upload`` — exact-duplicate warning in upload response
+- ``POST /api/ui-upload`` — exact-duplicate rejection at upload time
- ``GET /duplicates`` — duplicate management UI page
"""
import json
+import os
from unittest.mock import patch
import pytest
@@ -283,17 +284,25 @@ class TestGetFileDuplicates:
# ---------------------------------------------------------------------------
-# POST /api/ui-upload — exact-duplicate warning
+# POST /api/ui-upload — exact-duplicate rejection
# ---------------------------------------------------------------------------
-class TestUploadDuplicateWarning:
- """Tests for duplicate warning injected into the upload response."""
+class TestUploadDuplicateRejection:
+ """Tests for duplicate rejection at upload time.
+
+ When ``ENABLE_DEDUPLICATION`` is ``True`` (the default) and the uploaded
+ file's SHA-256 hash matches an already-processed document, the upload
+ endpoint must:
+ - return ``status: "duplicate"`` instead of ``"queued"``
+ - **not** enqueue a Celery task
+ - clean up the temporary file from disk
+ """
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_no_warning_for_unique_file(self, mock_delay, client: TestClient, tmp_path):
- """Uploading a unique file should not produce a duplicate_warning."""
+ """Uploading a unique file should not produce a duplicate response."""
mock_delay.return_value.id = "task-unique"
pdf = tmp_path / "unique.pdf"
pdf.write_bytes(b"%PDF-1.4\n%%EOF")
@@ -306,14 +315,12 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
- assert "duplicate_warning" not in data or data.get("duplicate_warning") is None
+ assert data["status"] == "queued"
+ assert "duplicate_of" not in data
@pytest.mark.integration
- @patch("app.tasks.process_document.process_document.delay")
- def test_warning_for_exact_duplicate(self, mock_delay, client: TestClient, db_session, tmp_path):
- """Uploading a file with the same hash as an existing record returns a warning."""
- mock_delay.return_value.id = "task-dup"
-
+ def test_exact_duplicate_rejected(self, client: TestClient, db_session, tmp_path):
+ """Uploading a file with the same hash as an existing record is rejected."""
# Create a real PDF with known content
pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF"
pdf = tmp_path / "existing.pdf"
@@ -335,16 +342,14 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
- assert "duplicate_warning" in data
- assert data["duplicate_warning"]["duplicate_type"] == "exact"
- assert data["duplicate_warning"]["original_file_id"] == existing.id
+ assert data["status"] == "duplicate"
+ assert "duplicate_of" in data
+ assert data["duplicate_of"]["duplicate_type"] == "exact"
+ assert data["duplicate_of"]["original_file_id"] == existing.id
@pytest.mark.integration
- @patch("app.tasks.process_document.process_document.delay")
- def test_upload_still_queued_despite_warning(self, mock_delay, client: TestClient, db_session, tmp_path):
- """Even when a duplicate is detected, the file should still be queued."""
- mock_delay.return_value.id = "task-still-queued"
-
+ def test_duplicate_not_enqueued(self, client: TestClient, db_session, tmp_path):
+ """When a duplicate is detected, no Celery task should be created."""
pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF"
pdf = tmp_path / "queue_test.pdf"
pdf.write_bytes(pdf_bytes)
@@ -354,16 +359,47 @@ class TestUploadDuplicateWarning:
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="queue_orig.pdf")
- with open(pdf, "rb") as f:
- response = client.post(
- "/api/ui-upload",
- files={"file": ("queue_test.pdf", f, "application/pdf")},
- )
+ with patch("app.tasks.process_document.process_document.delay") as mock_delay:
+ with open(pdf, "rb") as f:
+ response = client.post(
+ "/api/ui-upload",
+ files={"file": ("queue_test.pdf", f, "application/pdf")},
+ )
assert response.status_code == 200
data = response.json()
- assert "task_id" in data
- assert data["status"] == "queued"
+ assert data["status"] == "duplicate"
+ assert "task_id" not in data
+ mock_delay.assert_not_called()
+
+ @pytest.mark.integration
+ def test_duplicate_temp_file_cleaned_up(self, client: TestClient, db_session, tmp_path):
+ """The temporary file saved to disk should be removed for a duplicate."""
+ pdf_bytes = b"%PDF-1.4\ncleanup test content\n%%EOF"
+ pdf = tmp_path / "cleanup_test.pdf"
+ pdf.write_bytes(pdf_bytes)
+
+ from app.utils.file_operations import hash_file
+
+ filehash = hash_file(str(pdf))
+ _make_file(db_session, filehash=filehash, filename="cleanup_orig.pdf")
+
+ with patch("app.tasks.process_document.process_document.delay"):
+ with open(pdf, "rb") as f:
+ response = client.post(
+ "/api/ui-upload",
+ files={"file": ("cleanup_test.pdf", f, "application/pdf")},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ # The stored_filename is returned so we can verify cleanup
+ stored = data.get("stored_filename")
+ assert stored is not None
+
+ from app.config import settings
+
+ assert not os.path.exists(os.path.join(settings.workdir, stored))
# ---------------------------------------------------------------------------
diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py
index 8a53604c..babbbc30 100644
--- a/tests/test_local_auth.py
+++ b/tests/test_local_auth.py
@@ -322,6 +322,35 @@ def test_signup_duplicate_username(la_client, active_user):
assert "Username" in resp.json()["detail"]
+@pytest.mark.integration
+def test_signup_invalid_username_with_dot(la_client):
+ """POST /api/auth/signup returns 422 with a list detail when username contains a dot.
+
+ This is a regression test for the bug where ``data.detail`` was an array,
+ causing the frontend to display ``[object Object]`` instead of a message.
+ """
+ with patch("app.api.local_auth.settings") as mock_settings:
+ mock_settings.allow_local_signup = True
+ mock_settings.multi_user_enabled = True
+ mock_settings.email_host = "smtp.example.com"
+ resp = la_client.post(
+ "/api/auth/signup",
+ json={
+ "email": "a@example.com",
+ "username": "christian.louis",
+ "password": "password1",
+ "password_confirm": "password1",
+ },
+ )
+ assert resp.status_code == 422
+ detail = resp.json()["detail"]
+ # FastAPI returns a list of validation errors for Pydantic constraint failures.
+ # Each entry must be a dict with a "msg" key so the frontend can extract a readable message.
+ assert isinstance(detail, list), "detail should be a list for Pydantic validation errors"
+ assert len(detail) > 0
+ assert "msg" in detail[0]
+
+
@pytest.mark.integration
def test_signup_smtp_failure_cleans_up(la_client, la_session):
"""POST /api/auth/signup cleans up user records if email send fails."""
diff --git a/tests/test_send_to_all.py b/tests/test_send_to_all.py
index 0bab975f..3cf0c42f 100644
--- a/tests/test_send_to_all.py
+++ b/tests/test_send_to_all.py
@@ -360,6 +360,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -368,6 +369,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -397,6 +399,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -410,6 +413,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all.settings")
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
@patch("app.tasks.send_to_all._should_upload_to_paperless")
@@ -434,6 +438,7 @@ class TestSendToAllDestinations:
mock_paperless,
mock_nextcloud,
mock_should_s3,
+ mock_sharepoint,
mock_icloud,
mock_should_dropbox,
mock_settings,
@@ -456,6 +461,7 @@ class TestSendToAllDestinations:
mock_sftp.return_value = False
mock_email.return_value = False
mock_onedrive.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
@@ -478,12 +484,14 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_skips_unconfigured_services(
self,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -513,6 +521,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
@@ -534,6 +543,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -542,6 +552,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -571,6 +582,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -593,6 +605,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@@ -603,6 +616,7 @@ class TestSendToAllDestinations:
mock_validator,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -633,6 +647,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -653,6 +668,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@@ -661,6 +677,7 @@ class TestSendToAllDestinations:
mock_validator,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -691,6 +708,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Should not raise, should fall back to individual checks
@@ -710,6 +728,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -718,6 +737,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -747,6 +767,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.side_effect = Exception("Queue error")
@@ -758,6 +779,7 @@ class TestSendToAllDestinations:
assert "dropbox_error" in result.result["tasks"]
@patch("app.tasks.send_to_all._should_upload_to_icloud")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_email")
@@ -786,6 +808,7 @@ class TestSendToAllDestinations:
mock_email,
mock_onedrive,
mock_s3,
+ mock_sharepoint,
mock_icloud,
tmp_path,
):
@@ -808,6 +831,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Mock database session
@@ -836,12 +860,14 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
+ @patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_should_upload_check_exception_handling(
self,
mock_s3,
mock_icloud,
+ mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -871,6 +897,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
+ mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Should not raise, should treat as not configured
diff --git a/tests/test_session_management.py b/tests/test_session_management.py
new file mode 100644
index 00000000..e11c93ca
--- /dev/null
+++ b/tests/test_session_management.py
@@ -0,0 +1,699 @@
+"""Tests for server-side session management and QR code login.
+
+Covers:
+* Session creation, validation, revocation, and cleanup
+* "Log off everywhere" (revoke all sessions)
+* QR login challenge creation, validation, claiming, and status polling
+* Session management API endpoints (list, revoke, revoke-all)
+* QR auth API endpoints (challenge, status, claim)
+* Device info parsing from User-Agent strings
+"""
+
+from __future__ import annotations
+
+import secrets
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.database import Base
+from app.models import ApiToken, QRLoginChallenge, UserSession
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def db_session():
+ """Provide an in-memory SQLite session with all tables created."""
+ engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(engine)
+ TestSession = sessionmaker(bind=engine)
+ session = TestSession()
+ yield session
+ session.close()
+ Base.metadata.drop_all(engine)
+
+
+@pytest.fixture()
+def sample_user_id():
+ return "user@example.com"
+
+
+# ---------------------------------------------------------------------------
+# Model Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestUserSessionModel:
+ """Tests for the UserSession ORM model."""
+
+ def test_create_user_session(self, db_session: Session, sample_user_id: str):
+ """Test creating a UserSession record."""
+ now = datetime.now(timezone.utc)
+ session = UserSession(
+ session_token=secrets.token_urlsafe(64),
+ user_id=sample_user_id,
+ ip_address="192.168.1.1",
+ user_agent="Mozilla/5.0",
+ device_info="Chrome on macOS",
+ expires_at=now + timedelta(days=30),
+ )
+ db_session.add(session)
+ db_session.commit()
+
+ assert session.id is not None
+ assert session.user_id == sample_user_id
+ assert session.is_revoked is False
+ assert session.device_info == "Chrome on macOS"
+
+ def test_session_default_values(self, db_session: Session, sample_user_id: str):
+ """Test that default values are set correctly."""
+ session = UserSession(
+ session_token="test_token_123",
+ user_id=sample_user_id,
+ expires_at=datetime.now(timezone.utc) + timedelta(days=30),
+ )
+ db_session.add(session)
+ db_session.commit()
+
+ assert session.is_revoked is False
+ assert session.revoked_at is None
+
+
+@pytest.mark.unit
+class TestQRLoginChallengeModel:
+ """Tests for the QRLoginChallenge ORM model."""
+
+ def test_create_challenge(self, db_session: Session, sample_user_id: str):
+ """Test creating a QRLoginChallenge record."""
+ challenge = QRLoginChallenge(
+ challenge_token=secrets.token_urlsafe(64),
+ user_id=sample_user_id,
+ created_by_ip="10.0.0.1",
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
+ )
+ db_session.add(challenge)
+ db_session.commit()
+
+ assert challenge.id is not None
+ assert challenge.is_claimed is False
+ assert challenge.is_cancelled is False
+
+ def test_challenge_default_values(self, db_session: Session, sample_user_id: str):
+ """Test that QRLoginChallenge defaults are correct."""
+ challenge = QRLoginChallenge(
+ challenge_token="challenge_test_123",
+ user_id=sample_user_id,
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
+ )
+ db_session.add(challenge)
+ db_session.commit()
+
+ assert challenge.is_claimed is False
+ assert challenge.is_cancelled is False
+ assert challenge.claimed_at is None
+ assert challenge.device_name is None
+
+
+# ---------------------------------------------------------------------------
+# Session Manager Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestSessionManager:
+ """Tests for app/utils/session_manager.py functions."""
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_session_lifetime_days_default(self, mock_settings):
+ """Test default session lifetime."""
+ from app.utils.session_manager import get_session_lifetime_days
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+ assert get_session_lifetime_days() == 30
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_session_lifetime_days_custom(self, mock_settings):
+ """Test custom session lifetime overrides default."""
+ from app.utils.session_manager import get_session_lifetime_days
+
+ mock_settings.session_lifetime_custom_days = 90
+ mock_settings.session_lifetime_days = 30
+ assert get_session_lifetime_days() == 90
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_session_lifetime_days_minimum(self, mock_settings):
+ """Test session lifetime has a minimum of 1 day."""
+ from app.utils.session_manager import get_session_lifetime_days
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 0
+ assert get_session_lifetime_days() == 1
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_session_max_age_seconds(self, mock_settings):
+ """Test session max age in seconds."""
+ from app.utils.session_manager import get_session_max_age_seconds
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+ assert get_session_max_age_seconds() == 30 * 86400
+
+ @patch("app.utils.session_manager.settings")
+ def test_create_session(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test creating a server-side session."""
+ from app.utils.session_manager import create_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ user_session = create_session(
+ db_session,
+ user_id=sample_user_id,
+ ip_address="10.0.0.1",
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0",
+ )
+
+ assert user_session.id is not None
+ assert user_session.user_id == sample_user_id
+ assert user_session.ip_address == "10.0.0.1"
+ assert user_session.session_token is not None
+ assert len(user_session.session_token) > 32
+ assert user_session.is_revoked is False
+ assert user_session.device_info is not None
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_session_valid(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test validating a valid session."""
+ from app.utils.session_manager import create_session, validate_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ user_session = create_session(db_session, user_id=sample_user_id)
+ result = validate_session(db_session, user_session.session_token)
+ assert result is not None
+ assert result.id == user_session.id
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_session_revoked(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that revoked sessions are rejected."""
+ from app.utils.session_manager import create_session, validate_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ user_session = create_session(db_session, user_id=sample_user_id)
+ user_session.is_revoked = True
+ db_session.commit()
+
+ result = validate_session(db_session, user_session.session_token)
+ assert result is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_session_expired(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that expired sessions are rejected."""
+ from app.utils.session_manager import create_session, validate_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ user_session = create_session(db_session, user_id=sample_user_id)
+ user_session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
+ db_session.commit()
+
+ result = validate_session(db_session, user_session.session_token)
+ assert result is None
+
+ def test_validate_session_empty_token(self, db_session: Session):
+ """Test that empty token returns None."""
+ from app.utils.session_manager import validate_session
+
+ assert validate_session(db_session, "") is None
+ assert validate_session(db_session, None) is None
+
+ def test_validate_session_nonexistent_token(self, db_session: Session):
+ """Test that nonexistent token returns None."""
+ from app.utils.session_manager import validate_session
+
+ assert validate_session(db_session, "nonexistent_token_xyz") is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_revoke_session(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test revoking a single session."""
+ from app.utils.session_manager import create_session, revoke_session, validate_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ user_session = create_session(db_session, user_id=sample_user_id)
+ assert revoke_session(db_session, user_session.id, sample_user_id) is True
+
+ # Session should now be invalid
+ assert validate_session(db_session, user_session.session_token) is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_revoke_session_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that a user cannot revoke another user's session."""
+ from app.utils.session_manager import create_session, revoke_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ user_session = create_session(db_session, user_id=sample_user_id)
+ assert revoke_session(db_session, user_session.id, "other_user@example.com") is False
+
+ @patch("app.utils.session_manager.settings")
+ def test_revoke_all_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test revoking all sessions for a user."""
+ from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ s1 = create_session(db_session, user_id=sample_user_id)
+ s2 = create_session(db_session, user_id=sample_user_id)
+ s3 = create_session(db_session, user_id=sample_user_id)
+
+ count = revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=False)
+ assert count == 3
+
+ # All sessions should be revoked
+ active = list_user_sessions(db_session, sample_user_id)
+ assert len(active) == 0
+
+ @patch("app.utils.session_manager.settings")
+ def test_revoke_all_except_current(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test revoking all sessions except the current one."""
+ from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ s1 = create_session(db_session, user_id=sample_user_id)
+ s2 = create_session(db_session, user_id=sample_user_id)
+ s3 = create_session(db_session, user_id=sample_user_id)
+
+ count = revoke_all_sessions(
+ db_session,
+ sample_user_id,
+ except_session_id=s1.id,
+ revoke_api_tokens=False,
+ )
+ assert count == 2
+
+ active = list_user_sessions(db_session, sample_user_id)
+ assert len(active) == 1
+ assert active[0].id == s1.id
+
+ @patch("app.utils.session_manager.settings")
+ def test_revoke_all_includes_api_tokens(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that revoke-all also revokes API tokens."""
+ from app.utils.session_manager import create_session, revoke_all_sessions
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ create_session(db_session, user_id=sample_user_id)
+
+ # Create an API token
+ token = ApiToken(
+ owner_id=sample_user_id,
+ name="Test Token",
+ token_hash="abc123hash",
+ token_prefix="de_abc12345",
+ )
+ db_session.add(token)
+ db_session.commit()
+
+ revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=True)
+
+ db_session.refresh(token)
+ assert token.is_active is False
+
+ @patch("app.utils.session_manager.settings")
+ def test_list_user_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test listing active sessions for a user."""
+ from app.utils.session_manager import create_session, list_user_sessions
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ create_session(db_session, user_id=sample_user_id)
+ create_session(db_session, user_id=sample_user_id)
+ create_session(db_session, user_id="other@example.com")
+
+ sessions = list_user_sessions(db_session, sample_user_id)
+ assert len(sessions) == 2
+
+ @patch("app.utils.session_manager.settings")
+ def test_cleanup_expired_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test cleaning up expired sessions."""
+ from app.utils.session_manager import cleanup_expired_sessions, create_session
+
+ mock_settings.session_lifetime_custom_days = None
+ mock_settings.session_lifetime_days = 30
+
+ # Create a session that expired 10 days ago
+ session = create_session(db_session, user_id=sample_user_id)
+ session.expires_at = datetime.now(timezone.utc) - timedelta(days=10)
+ db_session.commit()
+
+ count = cleanup_expired_sessions(db_session)
+ assert count == 1
+
+
+# ---------------------------------------------------------------------------
+# QR Login Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestQRLogin:
+ """Tests for QR login challenge/claim flow."""
+
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test creating a QR login challenge."""
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id, ip_address="10.0.0.1")
+
+ assert challenge.id is not None
+ assert challenge.user_id == sample_user_id
+ assert challenge.challenge_token is not None
+ assert len(challenge.challenge_token) > 32
+ assert challenge.is_claimed is False
+ assert challenge.created_by_ip == "10.0.0.1"
+ # SQLite returns naive datetimes; normalise before comparison
+ expires = challenge.expires_at
+ if expires.tzinfo is None:
+ expires = expires.replace(tzinfo=timezone.utc)
+ assert expires > datetime.now(timezone.utc)
+
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that ttl_seconds can be derived from created_at and expires_at.
+
+ The API endpoint computes ttl_seconds = (expires_at - created_at) to
+ allow the client to run a countdown timer without comparing absolute
+ timestamps (avoiding clock-skew issues).
+ """
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+ assert ttl_seconds == 120
+
+ @patch("app.utils.session_manager.settings")
+ def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that a custom TTL is correctly reflected in the challenge timestamps."""
+ from app.utils.session_manager import create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 300
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
+ assert ttl_seconds == 300
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test validating a valid QR challenge."""
+ from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ result = validate_qr_challenge(db_session, challenge.challenge_token)
+ assert result is not None
+ assert result.id == challenge.id
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that expired challenges are rejected."""
+ from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
+ db_session.commit()
+
+ result = validate_qr_challenge(db_session, challenge.challenge_token)
+ assert result is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_qr_challenge_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that claimed challenges are rejected (replay protection)."""
+ from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ challenge.is_claimed = True
+ db_session.commit()
+
+ result = validate_qr_challenge(db_session, challenge.challenge_token)
+ assert result is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_validate_qr_challenge_cancelled(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that cancelled challenges are rejected."""
+ from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ challenge.is_cancelled = True
+ db_session.commit()
+
+ result = validate_qr_challenge(db_session, challenge.challenge_token)
+ assert result is None
+
+ def test_validate_qr_challenge_empty(self, db_session: Session):
+ """Test that empty challenge token returns None."""
+ from app.utils.session_manager import validate_qr_challenge
+
+ assert validate_qr_challenge(db_session, "") is None
+ assert validate_qr_challenge(db_session, None) is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_claim_qr_challenge_success(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test successfully claiming a QR challenge."""
+ from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ result = claim_qr_challenge(
+ db_session,
+ challenge.challenge_token,
+ device_name="Christian's iPhone 15 Pro",
+ ip_address="192.168.1.100",
+ )
+
+ assert result is not None
+ assert result["token"].startswith("de_")
+ assert result["token_id"] is not None
+ assert result["owner_id"] == sample_user_id
+ assert "QR" in result["name"]
+
+ # Challenge should now be claimed
+ db_session.refresh(challenge)
+ assert challenge.is_claimed is True
+ assert challenge.claimed_by_ip == "192.168.1.100"
+ assert challenge.device_name == "Christian's iPhone 15 Pro"
+
+ @patch("app.utils.session_manager.settings")
+ def test_claim_qr_challenge_replay_protection(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that a claimed challenge cannot be claimed again."""
+ from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+
+ # First claim succeeds
+ result1 = claim_qr_challenge(db_session, challenge.challenge_token)
+ assert result1 is not None
+
+ # Second claim fails (replay protection)
+ result2 = claim_qr_challenge(db_session, challenge.challenge_token)
+ assert result2 is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_claim_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that expired challenges cannot be claimed."""
+ from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
+ db_session.commit()
+
+ result = claim_qr_challenge(db_session, challenge.challenge_token)
+ assert result is None
+
+ def test_claim_qr_challenge_invalid_token(self, db_session: Session):
+ """Test claiming with an invalid token."""
+ from app.utils.session_manager import claim_qr_challenge
+
+ result = claim_qr_challenge(db_session, "nonexistent_token_xyz")
+ assert result is None
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_challenge_status_pending(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test getting status of a pending challenge."""
+ from app.utils.session_manager import create_qr_challenge, get_challenge_status
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ status = get_challenge_status(db_session, challenge.id, sample_user_id)
+
+ assert status is not None
+ assert status["status"] == "pending"
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_challenge_status_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test getting status of a claimed challenge."""
+ from app.utils.session_manager import claim_qr_challenge, create_qr_challenge, get_challenge_status
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ claim_qr_challenge(db_session, challenge.challenge_token, device_name="Test Device")
+
+ status = get_challenge_status(db_session, challenge.id, sample_user_id)
+ assert status is not None
+ assert status["status"] == "claimed"
+ assert status["device_name"] == "Test Device"
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_challenge_status_expired(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test getting status of an expired challenge."""
+ from app.utils.session_manager import create_qr_challenge, get_challenge_status
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
+ db_session.commit()
+
+ status = get_challenge_status(db_session, challenge.id, sample_user_id)
+ assert status["status"] == "expired"
+
+ @patch("app.utils.session_manager.settings")
+ def test_get_challenge_status_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
+ """Test that a user cannot see another user's challenge status."""
+ from app.utils.session_manager import create_qr_challenge, get_challenge_status
+
+ mock_settings.qr_login_challenge_ttl_seconds = 120
+
+ challenge = create_qr_challenge(db_session, sample_user_id)
+ status = get_challenge_status(db_session, challenge.id, "other@example.com")
+ assert status is None
+
+
+# ---------------------------------------------------------------------------
+# Device Info Parsing Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDeviceInfoParsing:
+ """Tests for User-Agent parsing."""
+
+ def test_chrome_macos(self):
+ from app.utils.session_manager import _parse_device_info
+
+ ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ result = _parse_device_info(ua)
+ assert "Chrome" in result
+ assert "macOS" in result
+
+ def test_safari_iphone(self):
+ from app.utils.session_manager import _parse_device_info
+
+ ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
+ result = _parse_device_info(ua)
+ assert "Safari" in result
+ assert "iPhone" in result
+
+ def test_firefox_windows(self):
+ from app.utils.session_manager import _parse_device_info
+
+ ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
+ result = _parse_device_info(ua)
+ assert "Firefox" in result
+ assert "Windows" in result
+
+ def test_edge_windows(self):
+ from app.utils.session_manager import _parse_device_info
+
+ ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
+ result = _parse_device_info(ua)
+ assert "Edge" in result
+ assert "Windows" in result
+
+ def test_android_chrome(self):
+ from app.utils.session_manager import _parse_device_info
+
+ ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.210 Mobile Safari/537.36"
+ result = _parse_device_info(ua)
+ assert "Chrome" in result
+ assert "Android" in result
+
+ def test_none_user_agent(self):
+ from app.utils.session_manager import _parse_device_info
+
+ assert _parse_device_info(None) is None
+
+ def test_empty_user_agent(self):
+ from app.utils.session_manager import _parse_device_info
+
+ assert _parse_device_info("") is None
+
+
+# ---------------------------------------------------------------------------
+# Config Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestSessionConfig:
+ """Tests for session-related configuration fields."""
+
+ def test_session_lifetime_days_field_exists(self):
+ """Verify session_lifetime_days field is defined in Settings."""
+ from app.config import Settings
+
+ # Check the field exists in the model
+ assert "session_lifetime_days" in Settings.model_fields
+
+ def test_session_lifetime_custom_days_field_exists(self):
+ """Verify session_lifetime_custom_days field is defined in Settings."""
+ from app.config import Settings
+
+ assert "session_lifetime_custom_days" in Settings.model_fields
+
+ def test_qr_login_challenge_ttl_field_exists(self):
+ """Verify qr_login_challenge_ttl_seconds field is defined in Settings."""
+ from app.config import Settings
+
+ assert "qr_login_challenge_ttl_seconds" in Settings.model_fields
diff --git a/tests/test_system_reset.py b/tests/test_system_reset.py
new file mode 100644
index 00000000..570ebd19
--- /dev/null
+++ b/tests/test_system_reset.py
@@ -0,0 +1,393 @@
+"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import 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 (
+ DocumentMetadata,
+ FileProcessingStep,
+ FileRecord,
+ ProcessingLog,
+)
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def reset_workdir():
+ """Create a temporary workdir populated with sample user data."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # Create data subdirectories with dummy files
+ for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
+ d = Path(tmpdir) / subdir
+ d.mkdir()
+ (d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
+
+ # Create cache files
+ for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
+ (Path(tmpdir) / cache).write_text("{}")
+
+ # Create a per-user watch folder cache
+ (Path(tmpdir) / "user_wf_42.json").write_text("{}")
+
+ # Create a loose PDF in workdir root
+ (Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
+
+ yield tmpdir
+
+
+@pytest.fixture
+def reset_db_session():
+ """Fresh in-memory database with sample user data rows."""
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ Session = sessionmaker(bind=engine)
+ session = Session()
+
+ # Seed with sample data
+ fr = FileRecord(
+ filehash="abc123",
+ original_filename="test.pdf",
+ local_filename="uuid.pdf",
+ file_size=1024,
+ mime_type="application/pdf",
+ )
+ session.add(fr)
+ session.flush()
+
+ session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
+ session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
+ session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
+ session.commit()
+
+ yield session
+
+ session.close()
+ Base.metadata.drop_all(bind=engine)
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for app/utils/system_reset.py
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestWipeWorkdirData:
+ """Tests for _wipe_workdir_data()."""
+
+ def test_removes_data_subdirs(self, reset_workdir):
+ from app.utils.system_reset import _wipe_workdir_data
+
+ result = _wipe_workdir_data(reset_workdir)
+
+ # All data subdirectories should be gone
+ for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
+ assert not (Path(reset_workdir) / subdir).exists()
+
+ assert result["deleted_dirs"] == 5
+
+ def test_removes_cache_files(self, reset_workdir):
+ from app.utils.system_reset import _wipe_workdir_data
+
+ result = _wipe_workdir_data(reset_workdir)
+
+ assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
+ assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
+ assert not (Path(reset_workdir) / "user_wf_42.json").exists()
+ assert result["deleted_files"] >= 3
+
+ def test_removes_loose_document_files(self, reset_workdir):
+ from app.utils.system_reset import _wipe_workdir_data
+
+ _wipe_workdir_data(reset_workdir)
+ assert not (Path(reset_workdir) / "abc123.pdf").exists()
+
+ def test_preserves_workdir_directory(self, reset_workdir):
+ from app.utils.system_reset import _wipe_workdir_data
+
+ _wipe_workdir_data(reset_workdir)
+ assert Path(reset_workdir).is_dir()
+
+ def test_handles_empty_workdir(self):
+ """No errors when workdir has no data dirs or caches."""
+ from app.utils.system_reset import _wipe_workdir_data
+
+ with tempfile.TemporaryDirectory() as empty_dir:
+ result = _wipe_workdir_data(empty_dir)
+ assert result["deleted_dirs"] == 0
+ assert result["deleted_files"] == 0
+
+
+@pytest.mark.unit
+class TestWipeDatabase:
+ """Tests for _wipe_database()."""
+
+ def test_deletes_all_user_data(self, reset_db_session):
+ from app.utils.system_reset import _wipe_database
+
+ result = _wipe_database(reset_db_session)
+
+ assert result.get("files", 0) >= 1
+ assert result.get("processing_logs", 0) >= 1
+ assert result.get("file_processing_steps", 0) >= 1
+ assert result.get("document_metadata", 0) >= 1
+
+ def test_tables_are_empty_after_wipe(self, reset_db_session):
+ from app.utils.system_reset import _wipe_database
+
+ _wipe_database(reset_db_session)
+
+ assert reset_db_session.query(FileRecord).count() == 0
+ assert reset_db_session.query(ProcessingLog).count() == 0
+ assert reset_db_session.query(FileProcessingStep).count() == 0
+ assert reset_db_session.query(DocumentMetadata).count() == 0
+
+
+@pytest.mark.unit
+class TestPerformFullReset:
+ """Tests for perform_full_reset()."""
+
+ def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
+ from app.utils.system_reset import perform_full_reset
+
+ with patch("app.utils.system_reset.settings") as mock_settings:
+ mock_settings.workdir = reset_workdir
+ result = perform_full_reset(reset_db_session)
+
+ assert "database" in result
+ assert "filesystem" in result
+ assert reset_db_session.query(FileRecord).count() == 0
+ assert not (Path(reset_workdir) / "original").exists()
+
+
+@pytest.mark.unit
+class TestPerformResetAndReimport:
+ """Tests for perform_reset_and_reimport()."""
+
+ def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
+ from app.utils.system_reset import perform_reset_and_reimport
+
+ with patch("app.utils.system_reset.settings") as mock_settings:
+ mock_settings.workdir = reset_workdir
+ mock_settings.watch_folders = ""
+ mock_settings.watch_folder_delete_after_process = False
+ result = perform_reset_and_reimport(reset_db_session)
+
+ reimport_dir = Path(reset_workdir) / "reimport"
+ assert reimport_dir.is_dir()
+ assert result["reimport"]["files_moved"] >= 1
+
+ # DB should be wiped
+ assert reset_db_session.query(FileRecord).count() == 0
+
+ # Reimport folder should contain the original file
+ reimport_files = list(reimport_dir.iterdir())
+ assert len(reimport_files) >= 1
+
+ def test_configures_watch_folder(self, reset_db_session, reset_workdir):
+ from app.utils.system_reset import perform_reset_and_reimport
+
+ with patch("app.utils.system_reset.settings") as mock_settings:
+ mock_settings.workdir = reset_workdir
+ mock_settings.watch_folders = "/some/other/folder"
+ mock_settings.watch_folder_delete_after_process = False
+ perform_reset_and_reimport(reset_db_session)
+
+ reimport_path = str(Path(reset_workdir) / "reimport")
+ # watch_folders should now include the reimport path
+ assert reimport_path in mock_settings.watch_folders
+
+
+@pytest.mark.unit
+class TestStartupReset:
+ """Tests for perform_startup_reset()."""
+
+ def test_startup_reset_calls_full_reset(self):
+ from app.utils.system_reset import perform_startup_reset
+
+ with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
+ with patch("app.database.SessionLocal") as mock_sl:
+ mock_db = mock_sl.return_value
+ perform_startup_reset()
+
+ mock_reset.assert_called_once_with(mock_db)
+ mock_db.close.assert_called_once()
+
+ def test_startup_reset_handles_errors(self):
+ from app.utils.system_reset import perform_startup_reset
+
+ with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
+ with patch("app.database.SessionLocal") as mock_sl:
+ mock_db = mock_sl.return_value
+ # Should not raise
+ perform_startup_reset()
+ mock_db.rollback.assert_called_once()
+ mock_db.close.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# Integration tests for API endpoints
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestSystemResetApi:
+ """Tests for the /api/admin/system-reset/ endpoints."""
+
+ def test_full_reset_requires_admin(self, client):
+ """Non-admin users get 403."""
+ response = client.post(
+ "/api/admin/system-reset/full",
+ json={"confirmation": "DELETE"},
+ )
+ assert response.status_code == 403
+
+ def test_full_reset_requires_feature_flag(self, client):
+ """Returns 404 when ENABLE_FACTORY_RESET is false."""
+ from app.api.system_reset import _require_admin
+
+ client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
+ try:
+ with patch("app.api.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = False
+ response = client.post(
+ "/api/admin/system-reset/full",
+ json={"confirmation": "DELETE"},
+ )
+ finally:
+ client.app.dependency_overrides.pop(_require_admin, None)
+ assert response.status_code == 404
+
+ def test_full_reset_requires_confirmation(self, client):
+ """Wrong confirmation string gets 400."""
+ from app.api.system_reset import _require_admin
+
+ client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
+ try:
+ with patch("app.api.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = True
+ response = client.post(
+ "/api/admin/system-reset/full",
+ json={"confirmation": "WRONG"},
+ )
+ finally:
+ client.app.dependency_overrides.pop(_require_admin, None)
+
+ assert response.status_code == 400
+
+ def test_reimport_requires_confirmation(self, client):
+ """Wrong confirmation string gets 400."""
+ from app.api.system_reset import _require_admin
+
+ client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
+ try:
+ with patch("app.api.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = True
+ response = client.post(
+ "/api/admin/system-reset/reimport",
+ json={"confirmation": "WRONG"},
+ )
+ finally:
+ client.app.dependency_overrides.pop(_require_admin, None)
+
+ assert response.status_code == 400
+
+ def test_status_endpoint(self, client):
+ """The status endpoint returns feature-flag state."""
+ from app.api.system_reset import _require_admin
+
+ client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
+ try:
+ response = client.get("/api/admin/system-reset/status")
+ finally:
+ client.app.dependency_overrides.pop(_require_admin, None)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "enabled" in data
+ assert "factory_reset_on_startup" in data
+
+ def test_full_reset_success(self, client):
+ """Full reset succeeds with correct confirmation and feature flag."""
+ from app.api.system_reset import _require_admin
+
+ client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
+ try:
+ with patch("app.api.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = True
+ with patch(
+ "app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
+ ):
+ response = client.post(
+ "/api/admin/system-reset/full",
+ json={"confirmation": "DELETE"},
+ )
+ finally:
+ client.app.dependency_overrides.pop(_require_admin, None)
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+ def test_reimport_success(self, client):
+ """Reimport succeeds with correct confirmation."""
+ from app.api.system_reset import _require_admin
+
+ client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
+ try:
+ with patch("app.api.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = True
+ with patch(
+ "app.utils.system_reset.perform_reset_and_reimport",
+ return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
+ ):
+ response = client.post(
+ "/api/admin/system-reset/reimport",
+ json={"confirmation": "REIMPORT"},
+ )
+ finally:
+ client.app.dependency_overrides.pop(_require_admin, None)
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+
+# ---------------------------------------------------------------------------
+# Integration tests for the view
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.integration
+class TestSystemResetView:
+ """Tests for the /admin/system-reset view."""
+
+ def test_view_redirects_when_disabled(self, client):
+ """When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
+ with client:
+ client.cookies.set("session", "test")
+ with patch("app.views.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = False
+ response = client.get("/admin/system-reset", follow_redirects=False)
+ # Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
+ assert response.status_code in (302, 307)
+
+ def test_view_requires_auth(self, client):
+ """Unauthenticated users are redirected away from the page."""
+ with patch("app.views.system_reset.settings") as mock_s:
+ mock_s.enable_factory_reset = True
+ mock_s.factory_reset_on_startup = False
+ response = client.get("/admin/system-reset", follow_redirects=False)
+ # Should redirect to login since there's no active session
+ assert response.status_code in (302, 307)
diff --git a/tests/test_upload_rate_limit.py b/tests/test_upload_rate_limit.py
new file mode 100644
index 00000000..4e23e6dc
--- /dev/null
+++ b/tests/test_upload_rate_limit.py
@@ -0,0 +1,265 @@
+"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py)."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from app.middleware.upload_rate_limit import compute_effective_limit
+
+# ---------------------------------------------------------------------------
+# Tests for compute_effective_limit (pure function, no Redis needed)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestComputeEffectiveLimit:
+ """Tests for the health-aware effective-limit calculation."""
+
+ def test_normal_conditions_return_base_limit(self):
+ """Under normal conditions the full base limit should be returned."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0)
+ assert effective == 20
+ assert factor == 1.0
+ assert reason == "normal"
+
+ def test_moderate_queue_halves_limit(self):
+ """Queue depth > 50 should halve the base limit."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0)
+ assert effective == 10
+ assert factor == 0.5
+ assert "moderate_queue" in reason
+
+ def test_high_queue_quarters_limit(self):
+ """Queue depth > 100 should quarter the base limit."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0)
+ assert effective == 5
+ assert factor == 0.25
+ assert "high_queue" in reason
+
+ def test_critical_queue_drops_to_ten_percent(self):
+ """Queue depth > 200 should drop to 10% of base limit."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0)
+ assert effective == 2
+ assert factor == 0.10
+ assert "critical_queue" in reason
+
+ def test_moderate_cpu_halves_limit(self):
+ """CPU load ratio > 1.5 should halve the base limit."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8)
+ assert effective == 10
+ assert factor == 0.5
+ assert "moderate_cpu" in reason
+
+ def test_high_cpu_quarters_limit(self):
+ """CPU load ratio > 2.0 should quarter the base limit."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5)
+ assert effective == 5
+ assert factor == 0.25
+ assert "high_cpu" in reason
+
+ def test_critical_cpu_drops_to_ten_percent(self):
+ """CPU load ratio > 3.0 should drop to 10% of base limit."""
+ effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0)
+ assert effective == 2
+ assert factor == 0.10
+ assert "critical_cpu" in reason
+
+ def test_worst_metric_wins(self):
+ """The lowest factor from queue and CPU should be applied."""
+ # Queue says 0.5, CPU says 0.25 → 0.25 wins
+ effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5)
+ assert effective == 5
+ assert factor == 0.25
+
+ def test_minimum_effective_limit_is_one(self):
+ """Even under extreme load the effective limit must be ≥ 1."""
+ effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0)
+ assert effective >= 1
+
+ def test_zero_base_limit_returns_zero(self):
+ """A base limit of 0 (disabled) should clamp to at least 1."""
+ effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0)
+ # max(1, int(0 * 1.0)) = max(1, 0) = 1
+ # A base_limit of 0 means "disabled" and is handled upstream
+ # (the dependency skips the check entirely), but the pure function
+ # still clamps to 1 as a safety net.
+ assert effective == 1
+
+
+# ---------------------------------------------------------------------------
+# Tests for the FastAPI dependency (mocked Redis)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestRequireUploadRateLimit:
+ """Tests for the require_upload_rate_limit FastAPI dependency."""
+
+ @pytest.mark.asyncio
+ async def test_allows_request_when_redis_unavailable(self):
+ """When Redis is down the dependency should fail open (allow the request)."""
+ from app.middleware.upload_rate_limit import require_upload_rate_limit
+
+ mock_request = MagicMock()
+ mock_request.session = {}
+ mock_request.client = MagicMock()
+ mock_request.client.host = "127.0.0.1"
+
+ with patch("app.middleware.upload_rate_limit._get_redis", return_value=None):
+ # Should NOT raise
+ result = await require_upload_rate_limit(mock_request)
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_allows_request_under_limit(self):
+ """A user below the rate limit should be allowed through."""
+ from app.middleware.upload_rate_limit import require_upload_rate_limit
+
+ mock_request = MagicMock()
+ mock_request.session = {"user": {"username": "testuser"}}
+ mock_request.client = MagicMock()
+ mock_request.client.host = "10.0.0.1"
+
+ mock_redis = MagicMock()
+ mock_pipe = MagicMock()
+ mock_pipe.execute.return_value = [
+ 0, # zremrangebyscore result
+ 5, # zcard — current count (under limit of 20)
+ [], # zrange oldest
+ ]
+ mock_redis.pipeline.return_value = mock_pipe
+ mock_redis.llen.return_value = 0 # empty queues
+
+ mock_pipe2 = MagicMock()
+ mock_pipe2.execute.return_value = [True, True]
+ # The second pipeline call (record upload)
+ mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
+
+ with (
+ patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
+ patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"),
+ patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1),
+ ):
+ result = await require_upload_rate_limit(mock_request)
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_rejects_request_over_limit(self):
+ """A user at or over the rate limit should receive a 429."""
+ from fastapi import HTTPException
+
+ from app.middleware.upload_rate_limit import require_upload_rate_limit
+
+ mock_request = MagicMock()
+ mock_request.session = {"user": {"username": "spammer"}}
+ mock_request.client = MagicMock()
+ mock_request.client.host = "10.0.0.2"
+
+ mock_redis = MagicMock()
+ mock_pipe = MagicMock()
+ mock_pipe.execute.return_value = [
+ 0, # zremrangebyscore
+ 20, # zcard — at limit
+ [("oldest_entry", 1000000.0)], # oldest entry for retry_after
+ ]
+ mock_redis.pipeline.return_value = mock_pipe
+ mock_redis.llen.return_value = 0
+
+ with (
+ patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
+ patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"),
+ patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await require_upload_rate_limit(mock_request)
+ assert exc_info.value.status_code == 429
+ assert "Retry-After" in exc_info.value.headers
+
+ @pytest.mark.asyncio
+ async def test_health_reduces_effective_limit(self):
+ """When queues are deep, the effective limit should drop, causing a 429 sooner."""
+ from fastapi import HTTPException
+
+ from app.middleware.upload_rate_limit import require_upload_rate_limit
+
+ mock_request = MagicMock()
+ mock_request.session = {"user": {"username": "normaluser"}}
+ mock_request.client = MagicMock()
+ mock_request.client.host = "10.0.0.3"
+
+ mock_redis = MagicMock()
+ mock_pipe = MagicMock()
+ # 12 uploads already — under normal limit of 20 but over health-reduced limit
+ mock_pipe.execute.return_value = [
+ 0, # zremrangebyscore
+ 12, # zcard — 12 uploads in window
+ [("oldest", 1000000.0)],
+ ]
+ mock_redis.pipeline.return_value = mock_pipe
+ # Simulate deep queue (>100) → effective limit = 25% of 20 = 5
+ mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total
+
+ with (
+ patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
+ patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"),
+ patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await require_upload_rate_limit(mock_request)
+ assert exc_info.value.status_code == 429
+
+ @pytest.mark.asyncio
+ async def test_falls_back_to_ip_when_no_user(self):
+ """Unauthenticated requests should use IP-based rate limiting."""
+ from app.middleware.upload_rate_limit import require_upload_rate_limit
+
+ mock_request = MagicMock()
+ mock_request.session = {}
+ mock_request.client = MagicMock()
+ mock_request.client.host = "192.168.1.100"
+
+ mock_redis = MagicMock()
+ mock_pipe = MagicMock()
+ mock_pipe.execute.return_value = [0, 0, []]
+ mock_redis.pipeline.return_value = mock_pipe
+ mock_redis.llen.return_value = 0
+
+ mock_pipe2 = MagicMock()
+ mock_pipe2.execute.return_value = [True, True]
+ mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
+
+ with (
+ patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
+ patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None),
+ patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
+ ):
+ result = await require_upload_rate_limit(mock_request)
+ assert result is None
+
+
+# ---------------------------------------------------------------------------
+# Tests for configuration
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestUploadRateLimitConfig:
+ """Tests for upload rate limit configuration settings."""
+
+ def test_settings_exist(self):
+ """Verify per-user upload rate limit settings are exposed in config."""
+ from app.config import settings
+
+ assert hasattr(settings, "upload_rate_limit_per_user")
+ assert hasattr(settings, "upload_rate_limit_window")
+
+ def test_sensible_defaults(self):
+ """Default values should be reasonable for a multi-user system."""
+ from app.config import settings
+
+ assert settings.upload_rate_limit_per_user >= 10
+ assert settings.upload_rate_limit_per_user <= 100
+ assert settings.upload_rate_limit_window >= 30
+ assert settings.upload_rate_limit_window <= 300
diff --git a/tests/test_upload_to_sharepoint.py b/tests/test_upload_to_sharepoint.py
new file mode 100644
index 00000000..1acfa40b
--- /dev/null
+++ b/tests/test_upload_to_sharepoint.py
@@ -0,0 +1,554 @@
+"""
+Tests for app/tasks/upload_to_sharepoint.py module.
+
+Covers get_sharepoint_token, resolve_sharepoint_drive,
+create_sharepoint_upload_session, upload_large_file_sharepoint,
+and upload_to_sharepoint Celery task.
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+
+@pytest.mark.unit
+class TestGetSharepointToken:
+ """Tests for get_sharepoint_token function."""
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_refresh_token_flow(self, mock_settings, mock_msal):
+ """Test token acquisition using refresh token."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = "refresh-token"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ mock_app = Mock()
+ mock_app.acquire_token_by_refresh_token.return_value = {
+ "access_token": "new-access-token",
+ }
+ mock_msal.return_value = mock_app
+
+ token = get_sharepoint_token()
+ assert token == "new-access-token"
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_refresh_token_updates_new_token(self, mock_settings, mock_msal):
+ """Test that a new refresh token updates settings."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = "old-refresh-token"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ mock_app = Mock()
+ mock_app.acquire_token_by_refresh_token.return_value = {
+ "access_token": "access-token",
+ "refresh_token": "new-refresh-token",
+ }
+ mock_msal.return_value = mock_app
+
+ get_sharepoint_token()
+ assert mock_settings.sharepoint_refresh_token == "new-refresh-token"
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_refresh_token_failure(self, mock_settings, mock_msal):
+ """Test error handling when refresh token fails."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = "expired-token"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ mock_app = Mock()
+ mock_app.acquire_token_by_refresh_token.return_value = {
+ "error": "invalid_grant",
+ "error_description": "Token expired",
+ }
+ mock_msal.return_value = mock_app
+
+ with pytest.raises(ValueError, match="Failed to get SharePoint access token"):
+ get_sharepoint_token()
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_client_credentials_flow(self, mock_settings, mock_msal):
+ """Test token acquisition using client credentials (org accounts)."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = ""
+ mock_settings.sharepoint_tenant_id = "org-tenant-id"
+
+ mock_app = Mock()
+ mock_app.acquire_token_for_client.return_value = {
+ "access_token": "client-cred-token",
+ }
+ mock_msal.return_value = mock_app
+
+ token = get_sharepoint_token()
+ assert token == "client-cred-token"
+
+ @patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_client_credentials_failure(self, mock_settings, mock_msal):
+ """Test error handling when client credentials flow fails."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "client-secret"
+ mock_settings.sharepoint_refresh_token = ""
+ mock_settings.sharepoint_tenant_id = "org-tenant-id"
+
+ mock_app = Mock()
+ mock_app.acquire_token_for_client.return_value = {
+ "error": "unauthorized_client",
+ "error_description": "Not authorized",
+ }
+ mock_msal.return_value = mock_app
+
+ with pytest.raises(ValueError, match="Failed to get SharePoint access token"):
+ get_sharepoint_token()
+
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_missing_client_id(self, mock_settings):
+ """Test error when client ID is missing."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = ""
+ mock_settings.sharepoint_client_secret = "secret"
+
+ with pytest.raises(ValueError, match="client ID and client secret"):
+ get_sharepoint_token()
+
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_no_refresh_token_common_tenant(self, mock_settings):
+ """Test error for common tenant without refresh token."""
+ from app.tasks.upload_to_sharepoint import get_sharepoint_token
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_refresh_token = ""
+ mock_settings.sharepoint_tenant_id = "common"
+
+ with pytest.raises(ValueError, match="either a refresh token or a non-'common' tenant ID"):
+ get_sharepoint_token()
+
+
+@pytest.mark.unit
+class TestResolveSharepointDrive:
+ """Tests for resolve_sharepoint_drive function."""
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_successful_resolution(self, mock_settings, mock_get):
+ """Test successful site and drive resolution."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 200
+ site_resp.json.return_value = {"id": "site-id-123"}
+
+ drives_resp = Mock()
+ drives_resp.status_code = 200
+ drives_resp.json.return_value = {
+ "value": [
+ {"id": "drive-1", "name": "Documents"},
+ {"id": "drive-2", "name": "Site Assets"},
+ ]
+ }
+
+ mock_get.side_effect = [site_resp, drives_resp]
+
+ site_id, drive_id = resolve_sharepoint_drive(
+ "access-token", "https://tenant.sharepoint.com/sites/mysite", "Documents"
+ )
+
+ assert site_id == "site-id-123"
+ assert drive_id == "drive-1"
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_library_not_found(self, mock_settings, mock_get):
+ """Test error when document library is not found."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 200
+ site_resp.json.return_value = {"id": "site-id-123"}
+
+ drives_resp = Mock()
+ drives_resp.status_code = 200
+ drives_resp.json.return_value = {
+ "value": [
+ {"id": "drive-1", "name": "Documents"},
+ ]
+ }
+
+ mock_get.side_effect = [site_resp, drives_resp]
+
+ with pytest.raises(RuntimeError, match="not found on site"):
+ resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/mysite", "NonExistentLibrary")
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_site_resolution_failure(self, mock_settings, mock_get):
+ """Test error when site resolution fails."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 404
+ site_resp.text = "Site not found"
+
+ mock_get.return_value = site_resp
+
+ with pytest.raises(RuntimeError, match="Failed to resolve SharePoint site"):
+ resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/nonexistent", "Documents")
+
+ def test_invalid_site_url(self):
+ """Test error with invalid site URL."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ with pytest.raises(ValueError, match="Invalid SharePoint site URL"):
+ resolve_sharepoint_drive("access-token", "not-a-url", "Documents")
+
+ @patch("app.tasks.upload_to_sharepoint.requests.get")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_case_insensitive_library_match(self, mock_settings, mock_get):
+ """Test that library name matching is case-insensitive."""
+ from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
+
+ mock_settings.http_request_timeout = 30
+
+ site_resp = Mock()
+ site_resp.status_code = 200
+ site_resp.json.return_value = {"id": "site-id"}
+
+ drives_resp = Mock()
+ drives_resp.status_code = 200
+ drives_resp.json.return_value = {
+ "value": [
+ {"id": "drive-1", "name": "Shared Documents"},
+ ]
+ }
+
+ mock_get.side_effect = [site_resp, drives_resp]
+
+ site_id, drive_id = resolve_sharepoint_drive(
+ "access-token", "https://tenant.sharepoint.com/sites/mysite", "shared documents"
+ )
+
+ assert drive_id == "drive-1"
+
+
+@pytest.mark.unit
+class TestCreateSharepointUploadSession:
+ """Tests for create_sharepoint_upload_session function."""
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_successful_session_creation(self, mock_settings, mock_post):
+ """Test successful upload session creation."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"uploadUrl": "https://upload.url/session123"}
+ mock_post.return_value = mock_response
+
+ url = create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
+
+ assert url == "https://upload.url/session123"
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_session_without_folder(self, mock_settings, mock_post):
+ """Test upload session creation without folder path."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"uploadUrl": "https://upload.url/session456"}
+ mock_post.return_value = mock_response
+
+ url = create_sharepoint_upload_session("test.pdf", None, "drive-id", "site-id", "access-token")
+
+ assert url == "https://upload.url/session456"
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_session_creation_failure(self, mock_settings, mock_post):
+ """Test error handling when session creation fails."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 403
+ mock_response.text = "Access denied"
+ mock_post.return_value = mock_response
+
+ with pytest.raises(RuntimeError, match="Failed to create SharePoint upload session"):
+ create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
+
+ @patch("app.tasks.upload_to_sharepoint.requests.post")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_url_encoding_special_characters(self, mock_settings, mock_post):
+ """Test that special characters in folder path are URL-encoded."""
+ from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
+
+ mock_settings.http_request_timeout = 30
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"uploadUrl": "https://upload.url/session"}
+ mock_post.return_value = mock_response
+
+ create_sharepoint_upload_session("file with spaces.pdf", "My Documents/Uploads", "drive-id", "site-id", "token")
+
+ call_url = mock_post.call_args[0][0]
+ assert "My%20Documents" in call_url
+ assert "file%20with%20spaces.pdf" in call_url
+
+
+@pytest.mark.unit
+class TestUploadLargeFileSharepoint:
+ """Tests for upload_large_file_sharepoint function."""
+
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_small_single_chunk_upload(self, mock_settings, mock_put, tmp_path):
+ """Test uploading a file that fits in a single chunk."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "small.pdf"
+ test_file.write_bytes(b"small content")
+
+ mock_response = Mock()
+ mock_response.status_code = 201
+ mock_response.json.return_value = {"id": "file123", "name": "small.pdf"}
+ mock_put.return_value = mock_response
+
+ result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+ assert result["id"] == "file123"
+
+ @patch("app.tasks.upload_to_sharepoint.time.sleep")
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_chunk_upload_retry_on_failure(self, mock_settings, mock_put, mock_sleep, tmp_path):
+ """Test retry logic when a chunk upload fails."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_fail = Mock()
+ mock_fail.status_code = 500
+
+ mock_success = Mock()
+ mock_success.status_code = 201
+ mock_success.json.return_value = {"id": "file123"}
+
+ mock_put.side_effect = [mock_fail, mock_success]
+
+ result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+ assert result["id"] == "file123"
+
+ @patch("app.tasks.upload_to_sharepoint.time.sleep")
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_chunk_upload_retry_on_exception(self, mock_settings, mock_put, mock_sleep, tmp_path):
+ """Test retry logic when an exception occurs during upload."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_success = Mock()
+ mock_success.status_code = 201
+ mock_success.json.return_value = {"id": "file123"}
+
+ mock_put.side_effect = [Exception("Network error"), mock_success]
+
+ result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+ assert result["id"] == "file123"
+
+ @patch("app.tasks.upload_to_sharepoint.time.sleep")
+ @patch("app.tasks.upload_to_sharepoint.requests.put")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_all_retries_exhausted(self, mock_settings, mock_put, mock_sleep, tmp_path):
+ """Test that exhausting all retries raises an exception."""
+ from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
+
+ mock_settings.http_request_timeout = 30
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_fail = Mock()
+ mock_fail.status_code = 500
+ mock_fail.text = "Server Error"
+ mock_put.return_value = mock_fail
+
+ with pytest.raises(RuntimeError, match="Failed to upload chunk"):
+ upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
+
+
+@pytest.mark.unit
+class TestUploadToSharepoint:
+ """Tests for upload_to_sharepoint Celery task."""
+
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ def test_file_not_found(self, mock_log):
+ """Test that missing file raises FileNotFoundError."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ with pytest.raises(FileNotFoundError):
+ upload_to_sharepoint.__wrapped__("/nonexistent/file.pdf", file_id=1)
+
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_missing_client_id(self, mock_settings, mock_log, tmp_path):
+ """Test error when SharePoint client ID is not configured."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = ""
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ with pytest.raises(ValueError, match="client ID is not configured"):
+ upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
+
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_missing_site_url(self, mock_settings, mock_log, tmp_path):
+ """Test error when SharePoint site URL is not configured."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_site_url = ""
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ with pytest.raises(ValueError, match="site URL is not configured"):
+ upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
+
+ @patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
+ @patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
+ @patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
+ @patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_successful_upload(
+ self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
+ ):
+ """Test successful SharePoint upload."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_refresh_token = "token"
+ mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
+ mock_settings.sharepoint_document_library = "Documents"
+ mock_settings.sharepoint_folder_path = "Uploads"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_token.return_value = "access-token"
+ mock_resolve.return_value = ("site-id", "drive-id")
+ mock_session.return_value = "https://upload.url/session"
+ mock_upload.return_value = {"webUrl": "https://tenant.sharepoint.com/sites/mysite/test.pdf"}
+
+ result = upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
+
+ assert result["status"] == "Completed"
+ assert "Uploads" in result["sharepoint_path"]
+ assert result["web_url"] == "https://tenant.sharepoint.com/sites/mysite/test.pdf"
+
+ @patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_upload_exception_handling(self, mock_settings, mock_log, mock_token, tmp_path):
+ """Test that upload errors are properly handled."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
+ mock_settings.sharepoint_folder_path = "Uploads"
+ mock_settings.sharepoint_document_library = "Documents"
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_token.side_effect = ValueError("Token error")
+
+ with pytest.raises(RuntimeError, match="Failed to upload"):
+ upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
+
+ @patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
+ @patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
+ @patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
+ @patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
+ @patch("app.tasks.upload_to_sharepoint.log_task_progress")
+ @patch("app.tasks.upload_to_sharepoint.settings")
+ def test_folder_override(
+ self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
+ ):
+ """Test that folder_override is used instead of settings."""
+ from app.tasks.upload_to_sharepoint import upload_to_sharepoint
+
+ mock_settings.sharepoint_client_id = "client-id"
+ mock_settings.sharepoint_client_secret = "secret"
+ mock_settings.sharepoint_refresh_token = "token"
+ mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
+ mock_settings.sharepoint_document_library = "Documents"
+ mock_settings.sharepoint_folder_path = "DefaultFolder"
+ mock_settings.sharepoint_tenant_id = "common"
+
+ test_file = tmp_path / "test.pdf"
+ test_file.write_bytes(b"test content")
+
+ mock_token.return_value = "access-token"
+ mock_resolve.return_value = ("site-id", "drive-id")
+ mock_session.return_value = "https://upload.url/session"
+ mock_upload.return_value = {"webUrl": "https://example.com/test.pdf"}
+
+ result = upload_to_sharepoint.apply(
+ args=[str(test_file)], kwargs={"file_id": 1, "folder_override": "CustomFolder"}
+ ).get()
+
+ # Verify the session was created with the override folder
+ mock_session.assert_called_once_with("test.pdf", "CustomFolder", "drive-id", "site-id", "access-token")
+ assert result["status"] == "Completed"
diff --git a/tests/test_views_dropbox.py b/tests/test_views_dropbox.py
index d42b9304..79dad26c 100644
--- a/tests/test_views_dropbox.py
+++ b/tests/test_views_dropbox.py
@@ -144,3 +144,37 @@ class TestDropboxViews:
assert response.status_code == 200
assert b"/Documents/Uploads" in response.content
assert b"Back to Integrations" in response.content
+
+
+@pytest.mark.integration
+class TestDropboxCallbackUrl:
+ """Tests that the callback_url is correctly passed to templates."""
+
+ def test_setup_page_includes_callback_url(self, client):
+ """Setup page should include the callback_url variable in its response."""
+ response = client.get("/dropbox-setup")
+ assert response.status_code == 200
+ # callback_url is embedded in the JS as the dropboxCallbackUrl constant
+ assert b"dropboxCallbackUrl" in response.content
+
+ def test_callback_page_includes_callback_url(self, client):
+ """Callback page should embed the server-side callback URL."""
+ response = client.get("/dropbox-callback?code=testcode")
+ assert response.status_code == 200
+ # callback_url is used as the redirectUri
+ assert b"redirectUri" in response.content
+
+ def test_setup_page_uses_public_base_url_when_set(self, client):
+ """When PUBLIC_BASE_URL is configured, it should appear in the redirect URI hint."""
+ with patch("app.views.dropbox.settings") as mock_settings:
+ mock_settings.public_base_url = "https://configured.example.com"
+ mock_settings.dropbox_app_key = ""
+ mock_settings.dropbox_app_secret = ""
+ mock_settings.dropbox_refresh_token = ""
+ mock_settings.dropbox_folder = ""
+ mock_settings.dropbox_allow_global_credentials_for_integrations = False
+ response = client.get("/dropbox-setup")
+ assert response.status_code == 200
+ # The configured public_base_url hostname must appear in the page (redirect URI display)
+ page_text = response.text
+ assert "configured.example.com/dropbox-callback" in page_text