Merge pull request #767 from christianlouis/copilot/fix-authentication-configuration-issues
fix(upload): reject exact duplicates at upload time; prevent duplicate mobile share uploads
This commit is contained in:
+28
-12
@@ -1268,7 +1268,12 @@ async def _save_upload_file_chunks(file: UploadFile, target_path: str, max_size:
|
|||||||
|
|
||||||
|
|
||||||
def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None:
|
def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None:
|
||||||
"""Check for an exact duplicate of the uploaded file and return a warning if found."""
|
"""Check for an exact duplicate of the uploaded file.
|
||||||
|
|
||||||
|
Returns a dict with duplicate info when the file's SHA-256 hash matches an
|
||||||
|
already-processed document, or ``None`` when no duplicate is found (or
|
||||||
|
deduplication is disabled).
|
||||||
|
"""
|
||||||
if not settings.enable_deduplication:
|
if not settings.enable_deduplication:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1287,8 +1292,8 @@ def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: s
|
|||||||
"original_file_id": existing.id,
|
"original_file_id": existing.id,
|
||||||
"original_filename": existing.original_filename,
|
"original_filename": existing.original_filename,
|
||||||
"message": (
|
"message": (
|
||||||
"This file appears to be an exact duplicate of an already-processed document. "
|
"This file is an exact duplicate of an already-processed document. "
|
||||||
"It will still be queued but will be flagged as a duplicate."
|
"It has not been queued for processing again."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1390,6 +1395,25 @@ async def ui_upload(
|
|||||||
logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
|
logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
|
||||||
file_size = written_size
|
file_size = written_size
|
||||||
|
|
||||||
|
# ── Early duplicate rejection ──────────────────────────────────────────
|
||||||
|
# Check for exact duplicates (same SHA-256 hash) BEFORE enqueuing a
|
||||||
|
# processing task. When deduplication is enabled and the file already
|
||||||
|
# exists, we skip processing entirely, clean up the temp file, and
|
||||||
|
# return the existing file's information to the caller.
|
||||||
|
exact_duplicate = _check_for_exact_duplicate(db, target_path, safe_filename)
|
||||||
|
if exact_duplicate:
|
||||||
|
# Remove the just-saved temp file — it's a duplicate.
|
||||||
|
try:
|
||||||
|
os.remove(target_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"status": "duplicate",
|
||||||
|
"original_filename": safe_filename,
|
||||||
|
"stored_filename": target_filename,
|
||||||
|
"duplicate_of": exact_duplicate,
|
||||||
|
}
|
||||||
|
|
||||||
# Determine if the file is a PDF or needs conversion
|
# Determine if the file is a PDF or needs conversion
|
||||||
mime_type, _ = mimetypes.guess_type(target_path)
|
mime_type, _ = mimetypes.guess_type(target_path)
|
||||||
file_ext = os.path.splitext(target_path)[1].lower()
|
file_ext = os.path.splitext(target_path)[1].lower()
|
||||||
@@ -1466,20 +1490,12 @@ async def ui_upload(
|
|||||||
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
|
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
|
||||||
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||||
|
|
||||||
# Check for exact duplicates (same SHA-256 hash) before returning.
|
return {
|
||||||
# This gives the caller an immediate warning without waiting for the pipeline.
|
|
||||||
# Only performed when deduplication is enabled in settings.
|
|
||||||
exact_duplicate_warning = _check_for_exact_duplicate(db, target_path, safe_filename)
|
|
||||||
|
|
||||||
response: dict = {
|
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"original_filename": safe_filename,
|
"original_filename": safe_filename,
|
||||||
"stored_filename": target_filename,
|
"stored_filename": target_filename,
|
||||||
}
|
}
|
||||||
if exact_duplicate_warning:
|
|
||||||
response["duplicate_warning"] = exact_duplicate_warning
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+22
-6
@@ -242,17 +242,33 @@ The DocuElevate browser extension uses this endpoint to send files directly from
|
|||||||
|
|
||||||
**POST** `/api/ui-upload`
|
**POST** `/api/ui-upload`
|
||||||
|
|
||||||
Upload one or more files from your computer for processing.
|
Upload a file from your computer for processing.
|
||||||
|
|
||||||
**Request**:
|
**Request**:
|
||||||
- Multipart form data with file(s)
|
- Multipart form data with a single `file` field
|
||||||
|
|
||||||
**Response**:
|
**Response** (new file):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"task_id": "abc-123",
|
||||||
"file_ids": [123, 124],
|
"status": "queued",
|
||||||
"message": "Files uploaded and queued for processing"
|
"original_filename": "invoice.pdf",
|
||||||
|
"stored_filename": "a1b2c3d4.pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (exact duplicate, when `ENABLE_DEDUPLICATION=True`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "duplicate",
|
||||||
|
"original_filename": "invoice.pdf",
|
||||||
|
"stored_filename": "e5f6a7b8.pdf",
|
||||||
|
"duplicate_of": {
|
||||||
|
"duplicate_type": "exact",
|
||||||
|
"original_file_id": 42,
|
||||||
|
"original_filename": "invoice.pdf",
|
||||||
|
"message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1581,14 +1581,30 @@ DocuElevate detects and flags documents that share the same content, even if the
|
|||||||
|
|
||||||
### Exact Duplicate Detection (SHA-256)
|
### Exact Duplicate Detection (SHA-256)
|
||||||
|
|
||||||
When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the new document is stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=<original_id>`) and no further processing is performed.
|
When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the upload is rejected immediately — no processing task is created, and the temporary file is removed from disk. The `/api/ui-upload` response returns `"status": "duplicate"` together with a `duplicate_of` object that identifies the original file.
|
||||||
|
|
||||||
|
If the same file somehow reaches the Celery worker (e.g. via a watch-folder ingest) it is still caught there and stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=<original_id>`) with no further processing.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ENABLE_DEDUPLICATION` | Hash-based exact duplicate detection on ingest. | `True` |
|
| `ENABLE_DEDUPLICATION` | Hash-based exact duplicate detection on ingest. | `True` |
|
||||||
| `SHOW_DEDUPLICATION_STEP` | Show the "Check for Duplicates" step in the processing timeline UI. | `True` |
|
| `SHOW_DEDUPLICATION_STEP` | Show the "Check for Duplicates" step in the processing timeline UI. | `True` |
|
||||||
|
|
||||||
An immediate duplicate warning is also included in the `/api/ui-upload` JSON response so the frontend can alert the user before the pipeline completes.
|
When the upload is an exact duplicate the `/api/ui-upload` response looks like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "duplicate",
|
||||||
|
"original_filename": "invoice.pdf",
|
||||||
|
"stored_filename": "abc-123.pdf",
|
||||||
|
"duplicate_of": {
|
||||||
|
"duplicate_type": "exact",
|
||||||
|
"original_file_id": 42,
|
||||||
|
"original_filename": "invoice.pdf",
|
||||||
|
"message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Near-Duplicate Detection (Content Similarity)
|
### Near-Duplicate Detection (Content Similarity)
|
||||||
|
|
||||||
|
|||||||
@@ -433,9 +433,17 @@ function _uploadSingleFile(file, progressBar, statusEl, onTerminal) {
|
|||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
const result = JSON.parse(xhr.responseText);
|
const result = JSON.parse(xhr.responseText);
|
||||||
progressBar.style.width = '100%';
|
progressBar.style.width = '100%';
|
||||||
|
|
||||||
|
if (result.status === 'duplicate' && result.duplicate_of) {
|
||||||
|
// Exact duplicate – no processing task was created
|
||||||
|
progressBar.className = 'file-progress-bar bg-yellow-400 h-2 rounded-full';
|
||||||
|
statusEl.textContent = `Duplicate – already processed (file #${result.duplicate_of.original_file_id})`;
|
||||||
|
statusEl.className = 'text-xs text-yellow-600 mt-1';
|
||||||
|
} else {
|
||||||
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
|
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
|
||||||
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
|
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
|
||||||
statusEl.className = 'text-xs text-green-600 mt-1';
|
statusEl.className = 'text-xs text-green-600 mt-1';
|
||||||
|
}
|
||||||
_onUploadSuccess();
|
_onUploadSuccess();
|
||||||
onTerminal();
|
onTerminal();
|
||||||
resolve({ rateLimited: false, retryAfterSeconds: 0 });
|
resolve({ rateLimited: false, retryAfterSeconds: 0 });
|
||||||
|
|||||||
@@ -110,7 +110,14 @@ export default function NotFoundScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addPendingFile } = useShare();
|
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<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (handledRef.current === pathname) return; // already handled
|
||||||
|
handledRef.current = pathname;
|
||||||
|
|
||||||
if (looksLikeFilePath(pathname)) {
|
if (looksLikeFilePath(pathname)) {
|
||||||
// Filesystem path from iOS "Open In…" – add the file to ShareContext
|
// Filesystem path from iOS "Open In…" – add the file to ShareContext
|
||||||
// and redirect to the Upload tab. UploadScreen will pick up the
|
// and redirect to the Upload tab. UploadScreen will pick up the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
import { normalizeFileUri } from "../utils/normalizeUri";
|
||||||
|
|
||||||
export interface SharedFile {
|
export interface SharedFile {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -35,11 +36,8 @@ export function ShareProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setPendingFiles((prev) => {
|
setPendingFiles((prev) => {
|
||||||
// Deduplicate by normalised URI so the same file is not uploaded twice
|
// Deduplicate by normalised URI so the same file is not uploaded twice
|
||||||
// when both the Linking handler (_layout.tsx) and +not-found.tsx fire.
|
// when both the Linking handler (_layout.tsx) and +not-found.tsx fire.
|
||||||
const normalize = (uri: string) => {
|
const norm = normalizeFileUri(file.uri);
|
||||||
try { return decodeURIComponent(uri); } catch { return uri; }
|
if (prev.some((f) => normalizeFileUri(f.uri) === norm)) return prev;
|
||||||
};
|
|
||||||
const norm = normalize(file.uri);
|
|
||||||
if (prev.some((f) => normalize(f.uri) === norm)) return prev;
|
|
||||||
return [...prev, file];
|
return [...prev, file];
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useShare } from "../context/ShareContext";
|
import { useShare } from "../context/ShareContext";
|
||||||
|
import { normalizeFileUri } from "../utils/normalizeUri";
|
||||||
import api from "../services/api";
|
import api from "../services/api";
|
||||||
|
|
||||||
/** Statuses that indicate processing has finished (no further polling needed). */
|
/** Statuses that indicate processing has finished (no further polling needed). */
|
||||||
@@ -63,6 +64,11 @@ export default function UploadScreen() {
|
|||||||
uploadsRef.current = uploads;
|
uploadsRef.current = uploads;
|
||||||
}, [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<Set<string>>(new Set());
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core helpers (declared before the effects that depend on them)
|
// Core helpers (declared before the effects that depend on them)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -103,12 +109,40 @@ export default function UploadScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
|
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]);
|
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const localUri = await ensureLocalUri(uri, filename);
|
const localUri = await ensureLocalUri(uri, filename);
|
||||||
const resp = await api.uploadFile(localUri, filename, mimeType);
|
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) =>
|
setUploads((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === id
|
item.id === id
|
||||||
@@ -116,7 +150,10 @@ export default function UploadScreen() {
|
|||||||
: item
|
: item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
// Allow retrying this URI on failure.
|
||||||
|
uploadedUrisRef.current.delete(normUri);
|
||||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||||
setUploads((prev) =>
|
setUploads((prev) =>
|
||||||
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
||||||
@@ -139,6 +176,21 @@ export default function UploadScreen() {
|
|||||||
try {
|
try {
|
||||||
const localUri = await ensureLocalUri(item.uri, item.filename);
|
const localUri = await ensureLocalUri(item.uri, item.filename);
|
||||||
const resp = await api.uploadFile(localUri, item.filename, item.mimeType);
|
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) =>
|
setUploads((prev) =>
|
||||||
prev.map((u) =>
|
prev.map((u) =>
|
||||||
u.id === item.id
|
u.id === item.id
|
||||||
@@ -146,6 +198,7 @@ export default function UploadScreen() {
|
|||||||
: u
|
: u
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||||
setUploads((prev) =>
|
setUploads((prev) =>
|
||||||
|
|||||||
@@ -66,10 +66,16 @@ export interface FileRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
task_id: string;
|
task_id?: string;
|
||||||
status: string;
|
status: string;
|
||||||
original_filename: string;
|
original_filename: string;
|
||||||
stored_filename: string;
|
stored_filename: string;
|
||||||
|
duplicate_of?: {
|
||||||
|
duplicate_type: string;
|
||||||
|
original_file_id: number;
|
||||||
|
original_filename: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+57
-21
@@ -3,11 +3,12 @@
|
|||||||
Covers:
|
Covers:
|
||||||
- ``GET /api/duplicates`` — list all exact-duplicate groups
|
- ``GET /api/duplicates`` — list all exact-duplicate groups
|
||||||
- ``GET /api/files/{id}/duplicates`` — per-file exact + near-duplicate info
|
- ``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
|
- ``GET /duplicates`` — duplicate management UI page
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -283,17 +284,25 @@ class TestGetFileDuplicates:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /api/ui-upload — exact-duplicate warning
|
# POST /api/ui-upload — exact-duplicate rejection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestUploadDuplicateWarning:
|
class TestUploadDuplicateRejection:
|
||||||
"""Tests for duplicate warning injected into the upload response."""
|
"""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
|
@pytest.mark.integration
|
||||||
@patch("app.tasks.process_document.process_document.delay")
|
@patch("app.tasks.process_document.process_document.delay")
|
||||||
def test_no_warning_for_unique_file(self, mock_delay, client: TestClient, tmp_path):
|
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"
|
mock_delay.return_value.id = "task-unique"
|
||||||
pdf = tmp_path / "unique.pdf"
|
pdf = tmp_path / "unique.pdf"
|
||||||
pdf.write_bytes(b"%PDF-1.4\n%%EOF")
|
pdf.write_bytes(b"%PDF-1.4\n%%EOF")
|
||||||
@@ -306,14 +315,12 @@ class TestUploadDuplicateWarning:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
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
|
@pytest.mark.integration
|
||||||
@patch("app.tasks.process_document.process_document.delay")
|
def test_exact_duplicate_rejected(self, client: TestClient, db_session, tmp_path):
|
||||||
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 is rejected."""
|
||||||
"""Uploading a file with the same hash as an existing record returns a warning."""
|
|
||||||
mock_delay.return_value.id = "task-dup"
|
|
||||||
|
|
||||||
# Create a real PDF with known content
|
# Create a real PDF with known content
|
||||||
pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF"
|
pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF"
|
||||||
pdf = tmp_path / "existing.pdf"
|
pdf = tmp_path / "existing.pdf"
|
||||||
@@ -335,16 +342,14 @@ class TestUploadDuplicateWarning:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "duplicate_warning" in data
|
assert data["status"] == "duplicate"
|
||||||
assert data["duplicate_warning"]["duplicate_type"] == "exact"
|
assert "duplicate_of" in data
|
||||||
assert data["duplicate_warning"]["original_file_id"] == existing.id
|
assert data["duplicate_of"]["duplicate_type"] == "exact"
|
||||||
|
assert data["duplicate_of"]["original_file_id"] == existing.id
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@patch("app.tasks.process_document.process_document.delay")
|
def test_duplicate_not_enqueued(self, client: TestClient, db_session, tmp_path):
|
||||||
def test_upload_still_queued_despite_warning(self, mock_delay, client: TestClient, db_session, tmp_path):
|
"""When a duplicate is detected, no Celery task should be created."""
|
||||||
"""Even when a duplicate is detected, the file should still be queued."""
|
|
||||||
mock_delay.return_value.id = "task-still-queued"
|
|
||||||
|
|
||||||
pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF"
|
pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF"
|
||||||
pdf = tmp_path / "queue_test.pdf"
|
pdf = tmp_path / "queue_test.pdf"
|
||||||
pdf.write_bytes(pdf_bytes)
|
pdf.write_bytes(pdf_bytes)
|
||||||
@@ -354,6 +359,7 @@ class TestUploadDuplicateWarning:
|
|||||||
filehash = hash_file(str(pdf))
|
filehash = hash_file(str(pdf))
|
||||||
_make_file(db_session, filehash=filehash, filename="queue_orig.pdf")
|
_make_file(db_session, filehash=filehash, filename="queue_orig.pdf")
|
||||||
|
|
||||||
|
with patch("app.tasks.process_document.process_document.delay") as mock_delay:
|
||||||
with open(pdf, "rb") as f:
|
with open(pdf, "rb") as f:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/ui-upload",
|
"/api/ui-upload",
|
||||||
@@ -362,8 +368,38 @@ class TestUploadDuplicateWarning:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "task_id" in data
|
assert data["status"] == "duplicate"
|
||||||
assert data["status"] == "queued"
|
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))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user