File size: 19,967 Bytes
ede7942
 
 
 
 
 
 
 
3a6f2bc
fb53d1b
ede7942
 
 
 
 
 
 
 
 
 
3a6f2bc
 
 
 
 
 
 
 
 
 
 
 
 
ede7942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e17442
 
 
 
ede7942
 
 
 
 
 
5e17442
ede7942
 
5e17442
ede7942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e17442
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ede7942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11463f1
 
ede7942
 
 
 
 
 
 
11463f1
 
 
 
 
 
 
ede7942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11463f1
 
ede7942
 
 
 
ad25fb2
 
 
ede7942
11463f1
 
 
 
 
 
 
 
 
 
 
fb53d1b
 
 
11463f1
 
 
 
 
fb53d1b
 
 
 
 
 
 
 
 
 
 
11463f1
fb53d1b
11463f1
 
 
 
fb53d1b
11463f1
 
ede7942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad25fb2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ede7942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
"""
Backend API tests for Anovo.
All service calls are mocked so no ML models or external services are required.
"""

import sys
import os
from unittest.mock import patch
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient

# Ensure the backend directory is on the path when running from backend/
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from main import app  # noqa: E402

client = TestClient(app)


class TestAccountDeletion:
    def test_delete_account_removes_user_and_commits(self):
        from routers.auth import delete_me

        db = MagicMock()
        user = MagicMock()
        with patch("routers.auth.get_user_by_id", return_value=user):
            delete_me(user_id=42, db=db)

        db.delete.assert_called_once_with(user)
        db.commit.assert_called_once_with()


# ── Health ────────────────────────────────────────────────────────────────────

class TestHealth:
    def test_health_check(self):
        response = client.get("/")
        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "ok"
        assert data["service"] == "Anovo API"


# ── Paraphrase ────────────────────────────────────────────────────────────────

class TestParaphrase:
    def test_paraphrase_success(self):
        with patch(
            "routers.paraphrase._paraphrase",
            return_value=("A quick fox leapt over a lazy dog.", "standard"),
        ):
            response = client.post("/api/paraphrase", json={"text": "The quick brown fox jumps over the lazy dog.", "intensity": 3})  # noqa: E501
        assert response.status_code == 200
        data = response.json()
        assert data["original"] == "The quick brown fox jumps over the lazy dog."
        assert data["paraphrased"] == "A quick fox leapt over a lazy dog."
        assert data["intensity"] == 3
        assert data["writing_mode"] == "standard"

    def test_paraphrase_intensity_bounds(self):
        with patch("routers.paraphrase._paraphrase", return_value=("result", "standard")):
            r1 = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 1})
            r5 = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 5})
        assert r1.status_code == 200
        assert r5.status_code == 200

    def test_paraphrase_invalid_intensity(self):
        response = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 10})
        assert response.status_code == 422

    def test_paraphrase_empty_text(self):
        response = client.post("/api/paraphrase", json={"text": "", "intensity": 3})
        assert response.status_code == 422

    def test_paraphrase_service_error(self):
        with patch("routers.paraphrase._paraphrase", side_effect=RuntimeError("model unavailable")):
            response = client.post("/api/paraphrase", json={"text": "Hello.", "intensity": 3})
        assert response.status_code == 500

    def test_contextual_refine_options(self):
        with patch(
            "routers.paraphrase._refine_selection",
            return_value=["Clear writing makes ideas easier to share.", "Good prose communicates ideas clearly."],
        ):
            response = client.post(
                "/api/paraphrase/refine",
                json={
                    "text": "Good writing helps people communicate ideas clearly.",
                    "selected_text": "Good writing helps people communicate ideas clearly.",
                    "kind": "sentence",
                    "writing_mode": "fluency",
                    "intensity": 3,
                    "count": 2,
                },
            )
        assert response.status_code == 200
        assert len(response.json()["suggestions"]) == 2


# ── Grammar ───────────────────────────────────────────────────────────────────

class TestGrammar:
    _good_errors = [
        {
            "message": "Possible agreement error",
            "offset": 5,
            "length": 3,
            "replacements": ["is"],
            "rule_id": "AGREEMENT",
            "category": "GRAMMAR",
        }
    ]

    def test_grammar_check_with_errors(self):
        with patch("routers.grammar.check_grammar", return_value=self._good_errors):
            response = client.post("/api/grammar-check", json={"text": "This are wrong.", "language": "en-US"})
        assert response.status_code == 200
        data = response.json()
        assert data["error_count"] == 1
        assert data["errors"][0]["rule_id"] == "AGREEMENT"

    def test_grammar_check_no_errors(self):
        with patch("routers.grammar.check_grammar", return_value=[]):
            response = client.post("/api/grammar-check", json={"text": "This is correct.", "language": "en-US"})
        assert response.status_code == 200
        data = response.json()
        assert data["error_count"] == 0
        assert data["errors"] == []

    def test_grammar_service_unavailable(self):
        with patch("routers.grammar.check_grammar", side_effect=RuntimeError("LanguageTool unreachable")):
            response = client.post("/api/grammar-check", json={"text": "Hello.", "language": "en-US"})
        assert response.status_code == 503

    def test_grammar_empty_text(self):
        response = client.post("/api/grammar-check", json={"text": "", "language": "en-US"})
        assert response.status_code == 422


# ── Summarize ─────────────────────────────────────────────────────────────────

_LONG_TEXT = (
    "Artificial intelligence is transforming the world in many ways. "
    "From healthcare to finance, AI is being applied to solve complex problems. "
    "Machine learning models can now outperform humans in specific tasks. "
    "However, ethical concerns and bias remain significant challenges. "
    "Research into explainable AI aims to make models more transparent and trustworthy."
)


class TestSummarize:
    def test_summarize_paragraph(self):
        with patch("routers.summarize._summarize", return_value="AI is changing the world."):
            response = client.post("/api/summarize", json={"text": _LONG_TEXT, "mode": "paragraph", "max_length": 150})
        assert response.status_code == 200
        data = response.json()
        assert data["summary"] == "AI is changing the world."
        assert data["mode"] == "paragraph"

    def test_summarize_bullet(self):
        bullets = "• AI is transforming healthcare.\n• Ethical concerns exist."
        with patch("routers.summarize._summarize", return_value=bullets):
            response = client.post("/api/summarize", json={"text": _LONG_TEXT, "mode": "bullet", "max_length": 150})
        assert response.status_code == 200
        assert response.json()["mode"] == "bullet"

    def test_summarize_text_too_short(self):
        response = client.post("/api/summarize", json={"text": "Too short.", "mode": "paragraph", "max_length": 150})
        assert response.status_code == 422

    def test_summarize_service_error(self):
        with patch("routers.summarize._summarize", side_effect=Exception("GPU OOM")):
            response = client.post("/api/summarize", json={"text": _LONG_TEXT, "mode": "paragraph", "max_length": 150})
        assert response.status_code == 500


# ── Translate ─────────────────────────────────────────────────────────────────

class TestTranslate:
    def test_translate_success(self):
        # _translate now also reports the source language it used (BUG-013).
        with patch("routers.translate._translate", return_value=("Bonjour le monde", "en")):
            response = client.post("/api/translate", json={"text": "Hello world", "source_language": "en", "target_language": "fr"})  # noqa: E501
        assert response.status_code == 200
        data = response.json()
        assert data["translated"] == "Bonjour le monde"
        assert data["source_language"] == "en"
        assert data["target_language"] == "fr"

    def test_translate_reports_detected_language(self):
        with patch("routers.translate._translate", return_value=("Hello", "fr")):
            response = client.post("/api/translate", json={"text": "Bonjour", "source_language": "auto", "target_language": "en"})  # noqa: E501
        data = response.json()
        assert data["detected_language"] == "fr"
        assert data["detected_language_name"] == "French"

    def test_translate_empty_text(self):
        response = client.post("/api/translate", json={"text": "", "source_language": "en", "target_language": "fr"})
        assert response.status_code == 422

    def test_translate_service_error(self):
        with patch("routers.translate._translate", side_effect=Exception("model not found")):
            response = client.post("/api/translate", json={"text": "Hello", "source_language": "en", "target_language": "xx"})  # noqa: E501
        assert response.status_code == 500


# ── Humanize ──────────────────────────────────────────────────────────────────

class TestHumanize:
    def test_humanize_success(self):
        mock_result = {"humanized": "Humanized text here.", "steps": {}}
        with patch("routers.humanize._humanize", return_value=mock_result):
            response = client.post("/api/humanize", json={"text": "The utilization of artificial intelligence is widespread."})  # noqa: E501
        assert response.status_code == 200
        data = response.json()
        assert data["humanized"] == "Humanized text here."

    def test_humanize_empty_text(self):
        response = client.post("/api/humanize", json={"text": ""})
        assert response.status_code == 422

    def test_humanize_service_error(self):
        with patch("routers.humanize._humanize", side_effect=Exception("pipeline failed")):
            response = client.post("/api/humanize", json={"text": "Some text to humanize."})
        assert response.status_code == 500


# ── Plagiarism ────────────────────────────────────────────────────────────────

class TestPlagiarism:
    def test_plagiarism_detected(self):
        result = {"similarity_score": 0.95, "is_plagiarized": True, "threshold": 0.8}
        with patch("routers.plagiarism.check_plagiarism", return_value=result):
            response = client.post("/api/plagiarism-check", json={"text": "This is the copied text.", "reference_text": "This is the copied text."})  # noqa: E501
        assert response.status_code == 200
        data = response.json()
        assert data["is_plagiarized"] is True
        assert abs(data["similarity_score"] - 0.95) < 1e-6

    def test_plagiarism_not_detected(self):
        result = {"similarity_score": 0.2, "is_plagiarized": False, "threshold": 0.8}
        with patch("routers.plagiarism.check_plagiarism", return_value=result):
            response = client.post("/api/plagiarism-check", json={"text": "The sky is blue.", "reference_text": "Python is a programming language."})  # noqa: E501
        assert response.status_code == 200
        assert response.json()["is_plagiarized"] is False

    def test_plagiarism_empty_text(self):
        response = client.post("/api/plagiarism-check", json={"text": "", "reference_text": "Some reference."})
        assert response.status_code == 422


# ── Tone ──────────────────────────────────────────────────────────────────────

class TestTone:
    _tone_result = {
        "tones": [
            {"label": "formal", "score": 0.8},
            {"label": "casual", "score": 0.1},
            {"label": "persuasive", "score": 0.05},
            {"label": "informative", "score": 0.05},
        ],
        "primary_tone": "formal",
    }

    def test_tone_detect_success(self):
        with patch("routers.tone.detect_tone", return_value=self._tone_result):
            response = client.post("/api/tone-detect", json={"text": "We must address this critical issue immediately."})  # noqa: E501
        assert response.status_code == 200
        data = response.json()
        assert data["primary_tone"] == "formal"
        assert len(data["tones"]) == 4

    def test_tone_empty_text(self):
        response = client.post("/api/tone-detect", json={"text": ""})
        assert response.status_code == 422

    def test_tone_service_error(self):
        with patch("routers.tone.detect_tone", side_effect=Exception("zero-shot failed")):
            response = client.post("/api/tone-detect", json={"text": "Some text."})
        assert response.status_code == 500


# ── Co-Writer ─────────────────────────────────────────────────────────────────

class TestCoWriter:
    def test_cowrite_success(self):
        suggestions = [" is a rapidly growing field.", " continues to evolve rapidly.", " shapes the modern economy."]
        # generate_suggestions now also returns a truncation advisory (BUG-038).
        with patch("routers.cowriter.generate_suggestions", return_value=(suggestions, "standard", None)):
            response = client.post("/api/co-write", json={"text": "Artificial intelligence", "max_tokens": 50, "num_suggestions": 3})  # noqa: E501
        assert response.status_code == 200
        data = response.json()
        assert len(data["suggestions"]) == 3
        assert data["action"] == "continue"
        assert data["tone"] == "match"
        assert data["model_used"] == "standard"
        assert data["prompt"] == "Artificial intelligence"
        # Default tone is "match"; a two-word draft has no voice to copy.
        assert "Match my voice" in data["advisory"]

    def test_cowrite_no_advisory_with_an_explicit_voice(self):
        with patch("routers.cowriter.generate_suggestions", return_value=(["a", "b", "c"], "standard", None)):
            response = client.post("/api/co-write", json={
                "text": "Artificial intelligence", "max_tokens": 50,
                "num_suggestions": 3, "tone": "professional",
            })
        assert response.json()["advisory"] is None

    def test_cowrite_refuses_json_input(self):
        # BUG-035, QA re-test: warning while still inventing prose from
        # {"name":"John"} was rejected. The co-writer now declines.
        with patch("routers.cowriter.generate_suggestions", return_value=(["a", "b", "c"], "standard", None)):
            response = client.post(
                "/api/co-write",
                json={"text": '{"name":"John","city":"Mumbai"}', "max_tokens": 50, "num_suggestions": 3},
            )
        assert response.status_code == 422
        assert "JSON" in response.json()["detail"]

    @pytest.mark.parametrize("text", ["123456789", "@@@@", "😀🔥"])
    def test_cowrite_refuses_input_with_nothing_to_work_from(self, text):
        # BUG-040, BUG-041 and the emoji case.
        with patch("routers.cowriter.generate_suggestions", return_value=(["a"], "standard", None)):
            response = client.post(
                "/api/co-write", json={"text": text, "max_tokens": 50, "num_suggestions": 1}
            )
        assert response.status_code == 422

    def test_cowrite_truncation_advisory_still_surfaces_for_prose(self):
        with patch("routers.cowriter.generate_suggestions",
                   return_value=(["a"], "standard", "Your draft was longer than the model's context window.")):
            response = client.post(
                "/api/co-write",
                json={"text": "A long prose draft about product launches.", "max_tokens": 50, "num_suggestions": 1},
            )
        assert "context window" in response.json()["advisory"]

    def test_cowrite_empty_text(self):
        response = client.post("/api/co-write", json={"text": "", "max_tokens": 50, "num_suggestions": 3})
        assert response.status_code == 422

    def test_cowrite_invalid_suggestions(self):
        response = client.post("/api/co-write", json={"text": "Hello", "max_tokens": 50, "num_suggestions": 10})
        assert response.status_code == 422

    def test_cowrite_service_error(self):
        with patch("routers.cowriter.generate_suggestions", side_effect=Exception("generation failed")):
            response = client.post("/api/co-write", json={"text": "The future of", "max_tokens": 50, "num_suggestions": 2})  # noqa: E501
        assert response.status_code == 500


# ── Documents ─────────────────────────────────────────────────────────────────

class TestDocuments:
    def test_extract_text_document_for_unified_workspace(self):
        response = client.post(
            "/api/documents/extract",
            files={"file": ("draft.txt", b"First paragraph.\n\nSecond paragraph.", "text/plain")},
        )
        assert response.status_code == 200
        data = response.json()
        assert data["filename"] == "draft.txt"
        assert data["text"] == "First paragraph.\n\nSecond paragraph."
        assert data["character_count"] == len(data["text"])

    def test_extract_rejects_unsupported_document(self):
        response = client.post(
            "/api/documents/extract",
            files={"file": ("draft.pdf", b"not a pdf", "application/pdf")},
        )
        assert response.status_code == 400


# ── Chat ──────────────────────────────────────────────────────────────────────

class TestChat:
    def test_chat_success(self):
        with patch("routers.chat.chat", return_value="AI is a branch of computer science."):
            response = client.post("/api/chat", json={"message": "What is AI?", "mode": "general", "history": []})
        assert response.status_code == 200
        data = response.json()
        assert data["reply"] == "AI is a branch of computer science."
        assert data["mode"] == "general"

    def test_chat_with_history(self):
        history = [{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}]
        with patch("routers.chat.chat", return_value="Sure, happy to help!"):
            response = client.post("/api/chat", json={"message": "Can you help me?", "mode": "academic", "history": history})  # noqa: E501
        assert response.status_code == 200

    def test_chat_empty_message(self):
        response = client.post("/api/chat", json={"message": "", "mode": "general", "history": []})
        assert response.status_code == 422

    def test_chat_ollama_unavailable(self):
        with patch("routers.chat.chat", side_effect=RuntimeError("Ollama not reachable")):
            response = client.post("/api/chat", json={"message": "Hello", "mode": "general", "history": []})
        assert response.status_code == 503