wendajia/backend/tests/test_auth.py
2026-09-22 17:57:35 +08:00

1413 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
from datetime import datetime, timedelta
from io import BytesIO
from fastapi.testclient import TestClient
from openpyxl import Workbook
from uuid import uuid4
import app.main as main_module
from app.main import app
from app.config import settings
def fake_ask_questions(payload, count, reference_text):
assert "shop_name" not in payload
return [f"本地测试问题{i + 1}?" for i in range(count)]
def answer_owner_token(client: TestClient, username: str = "operator") -> str:
password = "operator123456" if username == "operator" else "admin123456"
assert client.post("/api/admin/auth/login", json={"username": username, "password": password}).status_code == 200
response = client.get("/api/admin/auth/answer-link")
assert response.status_code == 200
return response.json()["data"]["token"]
def task_board_items(client: TestClient, **params):
response = client.get("/api/admin/task-board", params=params)
assert response.status_code == 200
parents = response.json()["data"]["items"]
return [
row
for parent in parents
for row in [parent, *(parent.get("claimed_tasks") or [])]
]
def test_admin_login_me_and_logout():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"})
assert login.status_code == 200
assert login.json()["data"]["user"]["role_code"] == "super_admin"
assert client.get("/api/admin/auth/me").status_code == 200
assert client.get("/api/admin/auth/login-logs").status_code == 200
assert client.post("/api/admin/auth/logout").status_code == 200
assert client.get("/api/admin/auth/me").status_code == 401
def test_wrong_password_is_rejected():
with TestClient(app) as client:
response = client.post("/api/admin/auth/login", json={"username": "admin", "password": "wrong"})
assert response.status_code == 401
def test_operator_cannot_access_super_admin_apis():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
assert client.get("/api/admin/auth/login-logs").status_code == 403
assert client.get("/api/admin/users").status_code == 403
assert client.get("/api/admin/settings").status_code == 403
def test_register_and_super_admin_user_crud():
username = f"tester_{uuid4().hex[:8]}"
with TestClient(app) as client:
registration = client.post("/api/admin/auth/register", json={"username": username, "password": "password123", "display_name": "测试账号"})
assert registration.status_code == 403
assert client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}).status_code == 200
created_response = client.post(
"/api/admin/users",
json={"username": username, "password": "password123", "display_name": "测试账号", "role_code": "operator"},
)
assert created_response.status_code == 201
records = client.get("/api/admin/users").json()["data"]
created = next(item for item in records if item["username"] == username)
assert client.get(f"/api/admin/users/{created['id']}").status_code == 200
update = client.patch(f"/api/admin/users/{created['id']}", json={"display_name": "已修改账号", "is_active": False})
assert update.status_code == 200
assert client.delete(f"/api/admin/users/{created['id']}").status_code == 200
assert client.get(f"/api/admin/users/{created['id']}").status_code == 404
def test_qa_generation_requires_product_and_model_key():
with TestClient(app) as client:
assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 401
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 400
def test_imported_questions_can_generate_configured_answer_count(monkeypatch):
captured = {}
def fake_bulk_answers(payload, questions):
captured["questions"] = questions
captured["answers_per_question"] = payload.answers_per_question
return [
{
"id": f"Q{index:03d}",
"question": question,
"answers": [f"回答{answer_index}" for answer_index in range(1, payload.answers_per_question + 1)],
"source": "批量导入(AI回答)",
"status": "待采纳",
"accepted": False,
}
for index, question in enumerate(questions, start=1)
]
monkeypatch.setattr(main_module, "generate_answers_for_questions", fake_bulk_answers)
payload = {
"product_name": "批量回答测试商品",
"questions": ["导入问题一?", "导入问题二?", "导入问题一?"],
"answers_per_question": 3,
}
with TestClient(app) as client:
assert client.post("/api/admin/qa/generate-answers", json=payload).status_code == 401
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
assert client.post(
"/api/admin/qa/generate-answers",
json={"questions": ["缺少商品信息?"], "answers_per_question": 2},
).status_code == 400
response = client.post("/api/admin/qa/generate-answers", json=payload)
assert response.status_code == 200
assert captured == {
"questions": ["导入问题一?", "导入问题二?"],
"answers_per_question": 3,
}
items = response.json()["data"]["items"]
assert [item["question"] for item in items] == captured["questions"]
assert all(len(item["answers"]) == 3 for item in items)
def test_bulk_answer_generation_persists_progress_and_retries_only_failed_items(monkeypatch):
state = {"allow_first_batch": False, "calls": []}
def fake_batch_answers(payload, questions):
state["calls"].append(tuple(questions))
if questions[0] == "问题1?" and not state["allow_first_batch"]:
raise RuntimeError("temporary model failure")
return [[f"{question}回答{i}" for i in range(1, payload.answers_per_question + 1)] for question in questions]
monkeypatch.setattr(main_module, "generate_answers_batch", fake_batch_answers)
monkeypatch.setattr(main_module.time, "sleep", lambda _: None)
payload = {
"product_name": "持久化批量测试商品",
"questions": [f"问题{i}?" for i in range(1, 8)],
"answers_per_question": 5,
}
with TestClient(app) as client:
assert client.post(
"/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"},
).status_code == 200
created = client.post("/api/admin/qa/generate-answer-jobs", json=payload)
assert created.status_code == 202
job_id = created.json()["data"]["id"]
failed = client.get(f"/api/admin/qa/generate-answer-jobs/{job_id}?include_items=true")
assert failed.status_code == 200
failed_data = failed.json()["data"]
assert failed_data["status"] == "partial_failed"
assert failed_data["completed"] == 1
assert failed_data["failed"] == 6
assert len(failed_data["items"]) == 1
assert len(failed_data["failed_items"]) == 6
assert state["calls"].count(("问题7?",)) == 1
state["allow_first_batch"] = True
retried = client.post(f"/api/admin/qa/generate-answer-jobs/{job_id}/retry")
assert retried.status_code == 202
completed = client.get(f"/api/admin/qa/generate-answer-jobs/{job_id}?include_items=true")
assert completed.status_code == 200
completed_data = completed.json()["data"]
assert completed_data["status"] == "completed"
assert completed_data["completed"] == 7
assert completed_data["failed"] == 0
assert len(completed_data["items"]) == 7
assert state["calls"].count(("问题7?",)) == 1
def test_accepted_qa_is_saved_to_library_without_duplicates():
unique_product = f"问答库测试商品-{uuid4().hex[:8]}"
payload = {
"product_name": unique_product,
"category": "测试品类",
"benefits": "测试功效",
"selling_points": "测试卖点",
"items": [{
"question": "这款商品适合什么人群?",
"answers": ["适合关注日常使用体验的人群。", "建议结合自身需求选择。"],
"source": "AI生成",
}],
}
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
created = client.post("/api/admin/qa-library", json=payload)
assert created.status_code == 200
assert created.json()["data"]["added_count"] == 1
duplicate = client.post("/api/admin/qa-library", json=payload)
assert duplicate.status_code == 200
assert duplicate.json()["data"]["added_count"] == 0
assert duplicate.json()["data"]["skipped_count"] == 1
records = client.get(f"/api/admin/qa-library?search={unique_product}")
assert records.status_code == 200
assert len(records.json()["data"]) == 1
assert records.json()["data"][0]["answers"] == payload["items"][0]["answers"]
entry_id = records.json()["data"][0]["id"]
edited = client.patch(
f"/api/admin/qa-library/{entry_id}",
json={
**payload,
"question": "编辑后的问题?",
"answers": ["编辑后的参考回答。"],
},
)
assert edited.status_code == 200
assert edited.json()["data"]["question"] == "编辑后的问题?"
assert edited.json()["data"]["answers"] == ["编辑后的参考回答。"]
def test_task_board_requires_login():
with TestClient(app) as client:
assert client.get("/api/admin/task-board").status_code == 401
assert client.get("/api/admin/task-board/999999").status_code == 401
assert client.post("/api/admin/task-board/999999/cancel").status_code == 401
def test_task_board_is_available_to_operator():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
response = client.get("/api/admin/task-board")
assert response.status_code == 200
assert isinstance(response.json()["data"]["items"], list)
assert response.json()["data"]["page"] == 1
counts = client.get("/api/admin/review-pending-count")
assert counts.status_code == 200
count_data = counts.json()["data"]
assert count_data["total"] == count_data["ask"] + count_data["answer"]
def test_taobao_id_login_is_shared_by_both_user_portals():
taobao_id = f"tb{uuid4().hex[:14]}"
with TestClient(app) as client:
owner_token = answer_owner_token(client)
answer_login = client.post(
"/api/answer/auth/taobao-login",
json={"taobao_id": taobao_id, "owner_token": owner_token},
)
assert answer_login.status_code == 200
answer_session = answer_login.json()["data"]
assert answer_session["user_id"] == taobao_id
assert answer_session["taobao_id"] == taobao_id
assert answer_session["account"] == taobao_id
assert answer_session["phone"] == ""
assert answer_session["token"].startswith("tb2_")
ask_login = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": taobao_id},
)
assert ask_login.status_code == 200
assert ask_login.json()["data"]["token"] == answer_session["token"]
invalid = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": "不合法 ID"},
)
assert invalid.status_code == 400
chinese_taobao_id = f"淘宝用户{uuid4().hex[:6]}"
chinese_login = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": chinese_taobao_id},
)
assert chinese_login.status_code == 200
assert chinese_login.json()["data"]["taobao_id"] == chinese_taobao_id
answer_chinese_login = client.post(
"/api/answer/auth/taobao-login",
json={"taobao_id": chinese_taobao_id, "owner_token": owner_token},
)
assert answer_chinese_login.status_code == 200
assert answer_chinese_login.json()["data"]["token"] == chinese_login.json()["data"]["token"]
invalid_symbol = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": "淘宝用户@测试"},
)
assert invalid_symbol.status_code == 400
def test_legacy_public_user_sessions_are_invalidated_and_rotated_on_taobao_login():
taobao_id = f"tb{uuid4().hex[:14]}"
legacy_token = uuid4().hex
with main_module.SessionLocal() as db:
user = main_module.AnswerUser(
public_id=f"U{uuid4().int % 10_000_000:07d}",
phone=f"tb_{uuid4().hex[:16]}",
taobao_id=taobao_id,
auth_token=legacy_token,
)
db.add(user)
db.commit()
with TestClient(app) as client:
expired = client.get(f"/api/ask/me/assignments?token={legacy_token}")
assert expired.status_code == 401
assert "淘宝ID重新登录" in expired.json()["detail"]
renewed = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id})
assert renewed.status_code == 200
renewed_token = renewed.json()["data"]["token"]
assert renewed_token.startswith("tb2_")
assert renewed_token != legacy_token
assert client.get(f"/api/ask/me/assignments?token={renewed_token}").status_code == 200
def test_answer_task_publish_remembers_operator_shop_name():
unique = uuid4().hex[:10]
remembered_shop = f"记忆店铺-{unique}"
with TestClient(app) as client:
assert client.post(
"/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"},
).status_code == 200
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
)
previous_shop_name = owner.last_answer_shop_name
public_user = main_module.AnswerUser(
public_id=f"U{unique[:7]}",
phone=f"136{uuid4().int % 100_000_000:08d}",
auth_token=uuid4().hex,
)
db.add(public_user)
db.flush()
submission = main_module.AnswerSubmission(
submission_no=f"ANS{unique.upper()}",
user_id=public_user.id,
owner_id=owner.id,
status="待筛选",
)
db.add(submission)
db.flush()
db.add(main_module.AnswerRecognizedQuestion(
submission_id=submission.id,
sequence_no=1,
product_title="记忆测试商品",
question_text="店铺名会被记住吗?",
answer_text="会。",
selected=True,
))
db.commit()
submission_id = submission.id
published = client.post(
f"/api/admin/answer-task-drafts/{submission_id}/publish",
json={"shop_name": remembered_shop},
)
assert published.status_code == 200
link_settings = client.get("/api/admin/auth/answer-link")
assert link_settings.status_code == 200
assert link_settings.json()["data"]["last_shop_name"] == remembered_shop
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
)
owner.last_answer_shop_name = previous_shop_name
db.commit()
def test_answer_review_rejection_requires_reason():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
missing = client.post("/api/admin/answer-completion-reviews/999999/decision", json={"approved": False})
assert missing.status_code == 404
def test_answer_review_detail_loads_screenshots_from_protected_endpoint():
unique = uuid4().hex[:10]
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
)
public_user = main_module.AnswerUser(
public_id=f"U{unique[:7]}",
phone=f"139{uuid4().int % 100_000_000:08d}",
auth_token=uuid4().hex,
)
db.add(public_user)
db.flush()
submission = main_module.AnswerSubmission(
submission_no=f"ANS{unique.upper()}",
user_id=public_user.id,
owner_id=owner.id,
shop_name="审核性能测试店铺",
status="待审核",
)
db.add(submission)
db.flush()
screenshot = main_module.AnswerScreenshot(
submission_id=submission.id,
stage="completion",
filename="completion.png",
content_type="image/png",
image_data=b"review-image-content",
)
db.add_all([
screenshot,
main_module.AnswerRecognizedQuestion(
submission_id=submission.id,
sequence_no=1,
product_title="审核性能测试商品",
question_text="测试问题?",
answer_text="测试回答。",
selected=True,
),
])
db.commit()
submission_id = submission.id
detail = client.get(f"/api/admin/answer-completion-reviews/{submission_id}")
assert detail.status_code == 200
screenshot_url = detail.json()["data"]["completion_screenshots"][0]["data_url"]
assert screenshot_url.startswith("/api/admin/answer-screenshots/")
assert "base64" not in detail.text
image = client.get(screenshot_url)
assert image.status_code == 200
assert image.headers["content-type"] == "image/png"
assert image.content == b"review-image-content"
def test_ask_review_detail_loads_screenshots_from_protected_endpoint():
unique = uuid4().hex[:10]
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
)
public_user = main_module.AnswerUser(
public_id=f"U{unique[:7]}",
phone=f"tb_{unique}",
taobao_id=f"tb{unique}",
auth_token=uuid4().hex,
)
task = main_module.AskTask(
task_no=f"TASK{unique.upper()}",
public_token=uuid4().hex,
owner_id=owner.id,
product_link="TEST-TOKEN",
shop_name="审核性能测试店铺",
product_name="审核性能测试商品",
status="已发布",
)
db.add_all([public_user, task])
db.flush()
task_id = task.id
task_no = task.task_no
creator_name = owner.display_name
question = main_module.AskQuestion(
task_id=task.id,
sequence_no=1,
question_text="提问详情是否轻量?",
selected=True,
)
assignment = main_module.AskAssignment(
task_id=task.id,
user_id=public_user.id,
status="待审核",
)
db.add_all([question, assignment])
db.flush()
db.add(main_module.AskAssignmentQuestion(
assignment_id=assignment.id,
question_id=question.id,
selected=True,
))
batch = main_module.AskAssignmentBatch(
assignment_id=assignment.id,
status="pending",
selected_question_ids=str(question.id),
)
db.add(batch)
db.flush()
db.add_all([
main_module.AskAssignmentBatchScreenshot(
batch_id=batch.id,
stage="question",
filename="question.png",
content_type="image/png",
image_data=b"ask-question-image",
),
main_module.AskAssignmentBatchScreenshot(
batch_id=batch.id,
stage="payment",
filename="payment.png",
content_type="image/png",
image_data=b"ask-payment-image",
),
])
db.commit()
batch_id = batch.id
assignment_id = assignment.id
detail = client.get(f"/api/admin/ask-batch-reviews/{batch_id}")
assert detail.status_code == 200
data = detail.json()["data"]
assert "base64" not in detail.text
assert data["creator_name"] == creator_name
question_url = data["question_screenshots"][0]["data_url"]
payment_url = data["payment_screenshots"][0]["data_url"]
assert question_url.startswith("/api/admin/ask-batch-screenshots/")
assert client.get(question_url).content == b"ask-question-image"
assert client.get(payment_url).content == b"ask-payment-image"
search = client.get("/api/admin/task-board", params={"keyword": task_no})
assert search.status_code == 200
assert search.json()["data"]["total"] == 1
assert search.json()["data"]["items"][0]["task_id"] == task_no
assert search.json()["data"]["items"][0]["creator_name"] == creator_name
decision = client.post(
f"/api/admin/ask-batch-reviews/{batch_id}/decision",
json={"approved": True},
)
assert decision.status_code == 200
assert "base64" not in decision.text
assert decision.json()["data"]["latest_review"]["approved"] is True
deleted = client.delete(f"/api/admin/task-board/ask-tasks/{task_id}")
assert deleted.status_code == 200
with main_module.SessionLocal() as db:
assert db.get(main_module.AskTask, task_id) is None
assert db.get(main_module.AskAssignment, assignment_id) is None
assert db.get(main_module.AskAssignmentBatch, batch_id) is None
def test_failed_answer_recognition_has_no_fake_questions_and_can_retry(monkeypatch):
unique = uuid4().hex[:10]
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
)
public_user = main_module.AnswerUser(
public_id=f"U{unique[:7]}",
phone=f"137{uuid4().int % 100_000_000:08d}",
auth_token=uuid4().hex,
)
db.add(public_user)
db.flush()
submission = main_module.AnswerSubmission(
submission_no=f"ANS{unique.upper()}",
user_id=public_user.id,
owner_id=owner.id,
status="待筛选",
)
db.add(submission)
db.flush()
db.add(main_module.AnswerScreenshot(
submission_id=submission.id,
stage="invited",
filename="invited.png",
content_type="image/png",
image_data=b"image-content",
))
db.commit()
submission_id = submission.id
monkeypatch.setattr(main_module, "extract_questions_from_images", lambda images: [])
main_module._process_answer_submission(submission_id)
with main_module.SessionLocal() as db:
failed = db.get(main_module.AnswerSubmission, submission_id)
assert failed.status == "识别失败"
assert failed.questions == []
monkeypatch.setattr(
main_module,
"extract_questions_from_images",
lambda images: [{"product_title": "识别商品", "question": "重新识别成功了吗?", "source_index": 0}],
)
monkeypatch.setattr(main_module, "generate_answer_for_question", lambda product, question: "重新识别成功。")
retried = client.post(f"/api/admin/answer-task-drafts/{submission_id}/retry-recognition")
assert retried.status_code == 200
with main_module.SessionLocal() as db:
completed = db.get(main_module.AnswerSubmission, submission_id)
assert completed.status == "待筛选"
assert [question.question_text for question in completed.questions] == ["重新识别成功了吗?"]
question_id = completed.questions[0].id
cleared = client.patch(
f"/api/admin/answer-task-drafts/questions/{question_id}",
json={"answer": ""},
)
assert cleared.status_code == 200
assert cleared.json()["data"]["answer"] == ""
with main_module.SessionLocal() as db:
assert db.get(main_module.AnswerRecognizedQuestion, question_id).answer_text == ""
def test_answer_rejection_reason_syncs_to_public_user(monkeypatch):
previous_key = settings.llm_api_key
settings.llm_api_key = ""
monkeypatch.setattr(
main_module,
"extract_questions_from_images",
lambda images: [{"product_title": "测试商品", "question": "测试问题?", "source_index": 0}],
)
taobao_id = f"tb{uuid4().hex[:14]}"
try:
with TestClient(app) as client:
owner_token = answer_owner_token(client)
login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token})
assert login.status_code == 200
token = login.json()["data"]["token"]
created = client.post(
"/api/answer/me/submissions",
data={"token": token, "owner_token": owner_token},
files=[("files", ("invited.png", b"test-image", "image/png"))],
)
assert created.status_code == 200
submission = created.json()["data"]
submission = client.get(
f"/api/answer/me/submissions/{submission['id']}?token={token}&owner_token={owner_token}"
).json()["data"]
question_id = submission["questions"][0]["id"]
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post(
f"/api/admin/answer-task-drafts/{submission['id']}/publish",
json={"shop_name": "测试店铺"},
).status_code == 200
completed = client.post(
f"/api/answer/me/submissions/{submission['id']}/completion",
data={"token": token, "owner_token": owner_token},
files=[
("files", ("done.png", b"done-image", "image/png")),
("payment_files", ("payment.png", b"payment-image", "image/png")),
],
)
assert completed.status_code == 200
no_reason = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False})
assert no_reason.status_code == 400
rejected = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False, "reason_code": "数量不足", "reason_text": "应完成1条回答,截图未完整展示"})
assert rejected.status_code == 200
current = client.get(f"/api/answer/me/submissions?token={token}&owner_token={owner_token}").json()["data"][0]
assert current["status"] == "未通过"
assert current["latest_review"]["reason_code"] == "数量不足"
assert "截图未完整展示" in current["latest_review"]["reason_text"]
finally:
settings.llm_api_key = previous_key
def test_ask_task_publish_appears_on_task_board(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generation_without_publish_fields = client.post(
"/api/admin/ask-tasks/generate",
json={"product_name": "测试商品", "question_count": 3},
)
assert generation_without_publish_fields.status_code == 200
assert generation_without_publish_fields.json()["data"]["status"] == "草稿"
generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert generated.status_code == 200
task = generated.json()["data"]
assert task["public_token"] is None
assert task["shop_name"] == "测试店铺"
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
assert published.status_code == 200
public_token = published.json()["data"]["public_token"]
assert public_token
assert public_token != task["task_id"]
assert len(public_token) >= 24
board = task_board_items(client, page_size=100)
created = next(item for item in board if item["task_id"] == task["task_id"])
assert created["task_type"] == "ask"
assert created["task_type_label"] == "提问任务"
assert created["qa_count"] == 3
assert created["public_token"] == public_token
assert created["shop_name"] == "测试店铺"
detail = client.get(created["detail_endpoint"])
assert detail.status_code == 200
assert detail.json()["data"]["shop_name"] == "测试店铺"
def test_imported_questions_can_publish_without_ai_generation():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
published = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "直接发布测试店铺",
"product_link": "TOKEN-DIRECT-PUBLISH",
"product_name": "直接发布测试商品",
"claim_questions_per_user": 20,
"questions": [
{"id": "new-import-1", "question": "批量导入问题一?", "source": "批量导入"},
{"id": "new-manual-2", "question": "自主添加问题二?", "source": "自主添加"},
],
},
)
assert published.status_code == 200
data = published.json()["data"]
assert data["status"] == "已发布"
assert data["shop_name"] == "直接发布测试店铺"
assert data["claim_questions_per_user"] == 20
assert [item["question"] for item in data["selected_questions"]] == ["批量导入问题一?", "自主添加问题二?"]
assert [item["source"] for item in data["selected_questions"]] == ["批量导入", "自主添加"]
missing_fields = client.post(
"/api/admin/ask-tasks/publish",
json={"questions": [{"question": "不能缺少发布信息?", "source": "批量导入"}]},
)
assert missing_fields.status_code == 400
over_limit = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "领取上限测试店铺",
"product_link": "TOKEN-CLAIM-LIMIT",
"claim_questions_per_user": 21,
"questions": [{"question": "超过领取上限?", "source": "自主添加"}],
},
)
assert over_limit.status_code == 400
assert over_limit.json()["detail"] == "每个用户可领取的问题数量必须在 2-20 条之间"
def test_ask_task_generation_honors_question_count(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "数量测试店铺",
"product_link": "https://item.example.com/count-test",
"product_name": "数量测试商品",
"question_count": 25,
},
)
assert generated.status_code == 200
questions = generated.json()["data"]["selected_questions"]
assert len(questions) == 25
assert len({item["question"] for item in questions}) == 25
def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
qr_data_url = "data:image/png;base64," + base64.b64encode(b"local-qr-image").decode()
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "二维码测试店铺",
"product_link": "",
"product_qr_image": qr_data_url,
"product_qr_image_name": "商品二维码.png",
"product_name": "二维码测试商品",
"question_count": 2,
},
)
assert generated.status_code == 200
task = generated.json()["data"]
assert task["product_link"] == ""
assert task["product_qr"]["content_type"] == "image/png"
assert task["product_qr"]["name"] == "商品二维码.png"
assert task["product_qr"]["data_url"] == qr_data_url
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
assert published.status_code == 200
assert published.json()["data"]["status"] == "已发布"
board = task_board_items(client, page_size=100)
created = next(item for item in board if item["task_id"] == task["task_id"])
detail = client.get(created["detail_endpoint"])
assert detail.status_code == 200
assert detail.json()["data"]["product_qr"]["data_url"] == qr_data_url
public_token = published.json()["data"]["public_token"]
assert public_token
def test_ask_task_regeneration_appends_to_existing_questions(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2})
assert first.status_code == 200
task = first.json()["data"]
assert len(task["selected_questions"]) == 2
second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2})
assert second.status_code == 200
questions = second.json()["data"]["selected_questions"]
assert len(questions) == 4
assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4]
def test_public_ask_assignment_expires_after_selection_timeout(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={"shop_name": "超时测试店铺", "product_link": "TOKEN-TIMEOUT-1", "product_name": "超时测试商品", "question_count": 2},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
taobao_id = f"tb{uuid4().hex[:14]}"
token = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]["token"]
task_url = f"/api/ask/tasks/{generated['task_id']}?token={token}"
first = client.get(task_url)
assert first.status_code == 200
first_data = first.json()["data"]
assignment_id = first_data["assignment_id"]
with main_module.SessionLocal() as db:
assignment = db.get(main_module.AskAssignment, assignment_id)
assignment.reservation_started_at = datetime.utcnow() - timedelta(minutes=2)
db.commit()
expired = client.get(task_url)
assert expired.status_code == 409
assert expired.json()["detail"] == "提问任务已失效,请点击刷新重新分配任务"
refreshed = client.get(task_url)
assert refreshed.status_code == 200
assert refreshed.json()["data"]["created_at"] != first_data["created_at"]
def test_public_ask_assignment_expires_after_upload_timeout(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={"shop_name": "超时测试店铺二", "product_link": "TOKEN-TIMEOUT-2", "product_name": "超时测试商品二", "question_count": 2},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
taobao_id = f"tb{uuid4().hex[:14]}"
token = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]["token"]
task_url = f"/api/ask/tasks/{generated['task_id']}?token={token}"
assignment = client.get(task_url).json()["data"]
selected = assignment["questions"][0]["id"]
confirmed = client.post(f"/api/ask/tasks/{generated['task_id']}/selection", json={"token": token, "question_ids": [selected]})
assert confirmed.status_code == 200
with main_module.SessionLocal() as db:
stored = db.get(main_module.AskAssignment, assignment["assignment_id"])
stored.reservation_started_at = datetime.utcnow() - timedelta(minutes=31)
db.commit()
expired = client.get(task_url)
assert expired.status_code == 409
assert expired.json()["detail"] == "提问任务已失效,请点击刷新重新分配任务"
refreshed = client.get(task_url)
assert refreshed.status_code == 200
assert refreshed.json()["data"]["status"] == "待选择"
def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
created = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": f"去重测试店铺-{uuid4().hex[:8]}",
"product_link": "TOKEN-IDEMPOTENT",
"product_name": "去重测试商品",
"question_count": 1,
},
)
assert created.status_code == 200
task_id = created.json()["data"]["id"]
first = client.post(f"/api/admin/ask-tasks/{task_id}/questions", json={"question": "问答库重复问题?", "source": "问答库"})
second = client.post(f"/api/admin/ask-tasks/{task_id}/questions", json={"question": "问答库重复问题?", "source": "问答库"})
assert first.status_code == 200 and second.status_code == 200
assert first.json()["data"]["id"] == second.json()["data"]["id"]
assert second.json()["data"]["source"] == "问答库"
question_id = first.json()["data"]["id"]
removed = client.delete(f"/api/admin/ask-tasks/{task_id}/questions/{question_id}")
assert removed.status_code == 200
refreshed = client.post(
"/api/admin/ask-tasks/generate",
json={
"task_id": task_id,
"shop_name": "去重测试店铺",
"product_link": "TOKEN-IDEMPOTENT",
"product_name": "去重测试商品",
"question_count": 1,
},
)
assert refreshed.status_code == 200
assert all(item["question"] != "问答库重复问题?" for item in refreshed.json()["data"]["selected_questions"])
def test_ask_question_file_import_parses_supported_text_formats():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
text_import = client.post(
"/api/admin/ask-questions/import",
files={"file": ("questions.txt", "Q1:第一条问题?\nA1:参考回答\nQ2:第二条问题?\nQ1:第一条问题?".encode("utf-8"), "text/plain")},
)
assert text_import.status_code == 200
assert text_import.json()["data"]["questions"] == ["第一条问题?", "第二条问题?"]
csv_import = client.post(
"/api/admin/ask-questions/import",
files={"file": ("questions.csv", "问题,答案\nCSV问题一?,参考回答\nCSV问题二?,参考回答".encode("utf-8"), "text/csv")},
)
assert csv_import.status_code == 200
assert csv_import.json()["data"]["questions"] == ["CSV问题一?", "CSV问题二?"]
workbook = Workbook()
sheet = workbook.active
sheet.append(["问题", "答案"])
sheet.append(["表格问题一?", "参考回答"])
sheet.append(["表格问题二?", "参考回答"])
workbook_file = BytesIO()
workbook.save(workbook_file)
workbook.close()
xlsx_import = client.post(
"/api/admin/ask-questions/import",
files={"file": ("questions.xlsx", workbook_file.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert xlsx_import.status_code == 200
assert xlsx_import.json()["data"]["questions"] == ["表格问题一?", "表格问题二?"]
json_import = client.post(
"/api/admin/ask-questions/import",
files={"file": ("questions.json", '[{"question":"JSON问题一?"},{"问题":"JSON问题二?"}]'.encode("utf-8"), "application/json")},
)
assert json_import.status_code == 200
assert json_import.json()["data"]["questions"] == ["JSON问题一?", "JSON问题二?"]
def test_ask_question_bulk_import_appends_and_deduplicates(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
created = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": f"批量导入测试店铺-{uuid4().hex[:8]}",
"product_link": "TOKEN-BULK-IMPORT",
"product_name": "批量导入测试商品",
"question_count": 1,
},
)
assert created.status_code == 200
task_id = created.json()["data"]["id"]
imported = client.post(
f"/api/admin/ask-tasks/{task_id}/questions/bulk",
json={"questions": ["批量问题一?", "批量问题二?", "批量问题一?", "本地测试问题1?"]},
)
assert imported.status_code == 200
result = imported.json()["data"]
assert result["added_count"] == 2
assert result["skipped_count"] == 2
assert [item["question"] for item in result["items"]] == ["批量问题一?", "批量问题二?"]
assert all(item["source"] == "批量导入" for item in result["items"])
def test_public_ask_user_gets_stable_unique_five_question_assignment():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "公共提问测试店铺",
"product_link": "https://item.example.com/public-ask",
"product_name": "公共提问测试商品",
"question_count": 8,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
taobao_id = f"tb{uuid4().hex[:14]}"
session = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]
url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}"
first = client.get(url)
second = client.get(url)
assert first.status_code == 200
assert second.status_code == 200
first_questions = first.json()["data"]["questions"]
second_questions = second.json()["data"]["questions"]
assert len(first_questions) == 5
assert len({item["id"] for item in first_questions}) == 5
assert [item["id"] for item in first_questions] == [item["id"] for item in second_questions]
no_selection = client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": session["token"], "question_ids": []},
)
assert no_selection.status_code == 400
chosen = [first_questions[0]["id"]]
selected = client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": session["token"], "question_ids": chosen},
)
assert selected.status_code == 200
assert selected.json()["data"]["status"] == "进行中"
assert [item["id"] for item in selected.json()["data"]["questions"] if item["selected"]] == chosen
submitted = client.post(
f"/api/ask/tasks/{generated['task_id']}/submission",
data={"token": session["token"], "payee_name": "测试用户"},
files=[
("files", ("question.png", b"question-image", "image/png")),
("payment_file", ("payment.png", b"payment-image", "image/png")),
],
)
assert submitted.status_code == 200
assert submitted.json()["data"]["status"] == "待审核"
batch_detail = client.get(
f"/api/admin/ask-batch-reviews/{submitted.json()['data']['batch_id']}"
)
assert batch_detail.status_code == 200
assert len(batch_detail.json()["data"]["question_screenshots"]) == 1
assert len(batch_detail.json()["data"]["payment_screenshots"]) == 1
reviews = client.get("/api/admin/ask-completion-reviews")
assert reviews.status_code == 200
review = next(item for item in reviews.json()["data"] if item["assignment_id"] == submitted.json()["data"]["assignment_id"])
assert review["status"] == "待审核"
decision = client.post(
f"/api/admin/ask-batch-reviews/{review['batch_id']}/decision",
json={"approved": True},
)
assert decision.status_code == 200
assert decision.json()["data"]["status"] == "已通过"
assert "base64" not in decision.text
board = task_board_items(client, page_size=100)
payout = next(
item
for item in board
if item.get("record_kind") == "ask_batch"
and item["id"] == review["batch_id"]
)
assert payout["status_label"] == "待返款"
assert payout["task_type"] == "ask"
assert payout["qa_count"] == 1
assert payout["completed_count"] == 0
payout_detail = client.get(payout["detail_endpoint"])
assert payout_detail.status_code == 200
assert len(payout_detail.json()["data"]["question_screenshots"]) == 1
assert len(payout_detail.json()["data"]["payment_screenshots"]) == 1
question_image_url = payout_detail.json()["data"]["question_screenshots"][0]["data_url"]
payment_image_url = payout_detail.json()["data"]["payment_screenshots"][0]["data_url"]
assert question_image_url.startswith("/api/admin/ask-")
assert payment_image_url.startswith("/api/admin/ask-")
assert "base64" not in payout_detail.text
assert client.get(question_image_url).content == b"question-image"
assert client.get(payment_image_url).content == b"payment-image"
refunded = client.post(
payout["refund_endpoint"],
files={"refund_file": ("refund.png", b"refund-image", "image/png")},
)
assert refunded.status_code == 200
assert refunded.json()["data"]["status_label"] == "已完成"
assert refunded.json()["data"]["refund_endpoint"] is None
board_after_refund = task_board_items(client, page_size=100)
completed_payout = next(
item
for item in board_after_refund
if item.get("record_kind") == "ask_batch"
and item["id"] == review["batch_id"]
)
assert completed_payout["status_label"] == "已完成"
assert completed_payout["completed_count"] == 1
audit_history = client.get("/api/admin/ask-completion-reviews").json()["data"]
assert not any(item["assignment_id"] == review["assignment_id"] for item in audit_history)
refreshed = client.get(url)
assert refreshed.json()["data"]["status"] == "已完成"
def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch):
previous_key = settings.llm_api_key
settings.llm_api_key = ""
monkeypatch.setattr(
main_module,
"extract_questions_from_images",
lambda images: [{"product_title": "测试商品", "question": "测试问题?", "source_index": 0}],
)
taobao_id = f"tb{uuid4().hex[:14]}"
try:
with TestClient(app) as client:
owner_token = answer_owner_token(client)
login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token})
token = login.json()["data"]["token"]
created = client.post(
"/api/answer/me/submissions",
data={"token": token, "owner_token": owner_token},
files=[("files", ("invited.png", b"invited-image", "image/png"))],
).json()["data"]
created = client.get(
f"/api/answer/me/submissions/{created['id']}?token={token}&owner_token={owner_token}"
).json()["data"]
question_id = created["questions"][0]["id"]
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post(
f"/api/admin/answer-task-drafts/{created['id']}/publish",
json={"shop_name": "测试店铺"},
).status_code == 200
completed = client.post(
f"/api/answer/me/submissions/{created['id']}/completion",
data={"token": token, "owner_token": owner_token},
files=[
("files", ("done.png", b"done-image", "image/png")),
("payment_files", ("payment.png", b"payment-image", "image/png")),
],
)
assert completed.status_code == 200
approved = client.post(
f"/api/admin/answer-completion-reviews/{created['id']}/decision",
json={"approved": True},
)
assert approved.status_code == 200
board = task_board_items(client, page_size=100)
payout = next(
item
for item in board
if item.get("record_kind") == "answer_submission"
and item["id"] == created["id"]
)
assert payout["status_label"] == "待返款"
detail = client.get(f"/api/admin/task-board/{created['id']}")
assert detail.status_code == 200
assert len(detail.json()["data"]["completion_screenshots"]) == 1
assert len(detail.json()["data"]["payment_screenshots"]) == 1
refunded = client.post(
payout["refund_endpoint"],
files={"refund_file": ("refund.png", b"refund-image", "image/png")},
)
assert refunded.status_code == 200
assert refunded.json()["data"]["status_label"] == "已完成"
assert refunded.json()["data"]["refund_endpoint"] is None
user_detail = client.get(
f"/api/answer/me/submissions/{created['id']}?token={token}&owner_token={owner_token}"
)
assert user_detail.status_code == 200
assert user_detail.json()["data"]["status"] == "已完成"
assert len(user_detail.json()["data"]["refund_screenshots"]) == 1
board_after_refund = task_board_items(client, page_size=100)
completed_payout = next(
item
for item in board_after_refund
if item.get("record_kind") == "answer_submission"
and item["id"] == created["id"]
)
assert completed_payout["status_label"] == "已完成"
review_history = client.get("/api/admin/answer-completion-reviews").json()["data"]
assert not any(item["id"] == created["id"] for item in review_history)
finally:
settings.llm_api_key = previous_key
def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "唯一分配测试店铺",
"product_link": "https://item.example.com/unique-allocation",
"product_name": "跨用户唯一分配测试商品",
"question_count": 10,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
sessions = []
for _ in range(3):
taobao_id = f"tb{uuid4().hex[:14]}"
sessions.append(client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"])
first_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[0]['token']}"
second_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[1]['token']}"
third_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[2]['token']}"
first = client.get(first_url)
second = client.get(second_url)
assert first.status_code == 200
assert second.status_code == 200
first_ids = {item["id"] for item in first.json()["data"]["questions"]}
second_ids = {item["id"] for item in second.json()["data"]["questions"]}
assert len(first_ids) == 5
assert len(second_ids) == 5
assert first_ids.isdisjoint(second_ids)
assert len(first_ids | second_ids) == 10
# Returning users keep their original allocation.
assert {item["id"] for item in client.get(first_url).json()["data"]["questions"]} == first_ids
exhausted = client.get(third_url)
assert exhausted.status_code == 409
assert exhausted.json()["detail"] == "本次任务已被领取完,请关注下一个任务!"
def test_unselected_ask_questions_are_released_immediately(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post(
"/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"},
).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "未领取释放测试店铺",
"product_link": "TOKEN-RELEASE-UNSELECTED",
"product_name": "未领取释放测试商品",
"question_count": 8,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
first_session = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
).json()["data"]
second_session = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
).json()["data"]
first_url = f"/api/ask/tasks/{generated['task_id']}?token={first_session['token']}"
first_assignment = client.get(first_url).json()["data"]
first_ids = {item["id"] for item in first_assignment["questions"]}
assert len(first_ids) == 5
selected_ids = set(list(first_ids)[:3])
confirmed = client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": first_session["token"], "question_ids": list(selected_ids)},
)
assert confirmed.status_code == 200
confirmed_ids = {item["id"] for item in confirmed.json()["data"]["questions"]}
assert confirmed_ids == selected_ids
second_url = f"/api/ask/tasks/{generated['task_id']}?token={second_session['token']}"
second_assignment = client.get(second_url)
assert second_assignment.status_code == 200
second_ids = {item["id"] for item in second_assignment.json()["data"]["questions"]}
assert len(second_ids) == 5
assert second_ids == {
item["id"]
for item in generated["selected_questions"]
} - selected_ids
def test_answer_submission_requires_owner_link_and_allows_at_most_two_images():
with TestClient(app) as client:
missing_owner = client.post(
"/api/answer/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
)
assert missing_owner.status_code == 400
owner_token = answer_owner_token(client)
login = client.post(
"/api/answer/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}", "owner_token": owner_token},
)
assert login.status_code == 200
token = login.json()["data"]["token"]
too_many = client.post(
"/api/answer/me/submissions",
data={"token": token, "owner_token": owner_token},
files=[
("files", (f"shot-{index}.png", b"image", "image/png"))
for index in range(3)
],
)
assert too_many.status_code == 400
assert too_many.json()["detail"] == "一次最多上传2张截图"
def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after_all_paid(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post(
"/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"},
).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "分批保留测试店铺",
"product_link": "https://item.example.com/batch-history",
"product_name": "分批保留测试商品",
"question_count": 2,
"claim_questions_per_user": 2,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
session = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
).json()["data"]
task_url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}"
assignment = client.get(task_url).json()["data"]
first_question_id = assignment["questions"][0]["id"]
assert client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": session["token"], "question_ids": [first_question_id]},
).status_code == 200
first_batch = client.post(
f"/api/ask/tasks/{generated['task_id']}/submission",
data={"token": session["token"], "payee_name": "测试用户"},
files=[
("files", ("question-1.png", b"question-1", "image/png")),
("payment_file", ("payment-1.png", b"payment-1", "image/png")),
],
).json()["data"]
assert client.post(
f"/api/admin/ask-batch-reviews/{first_batch['batch_id']}/decision",
json={"approved": True},
).status_code == 200
assert client.post(
f"/api/admin/task-board/ask-batches/{first_batch['batch_id']}/confirm-refund",
files={"refund_file": ("refund-1.png", b"refund-1", "image/png")},
).status_code == 200
board = task_board_items(client, task_type="ask", page_size=100)
parent = next(item for item in board if item.get("record_kind") == "ask_task" and item["id"] == generated["id"])
assert parent["status_label"] == "进行中"
assert parent["completed_count"] == 1
next_assignment = client.get(f"{task_url}&refresh=true").json()["data"]
assert next_assignment["status"] == "待选择"
with main_module.SessionLocal() as db:
stored = db.get(main_module.AskAssignment, next_assignment["assignment_id"])
stored.reservation_started_at = datetime.utcnow() - timedelta(minutes=2)
db.commit()
expired = client.get(task_url)
assert expired.status_code == 409
with main_module.SessionLocal() as db:
stored = db.get(main_module.AskAssignment, next_assignment["assignment_id"])
assert len(stored.batches) == 1
assert stored.batches[0].status == "paid"
final_assignment = client.get(f"{task_url}&refresh=true").json()["data"]
remaining_question_id = next(
item["id"] for item in final_assignment["questions"] if item["id"] != first_question_id
)
assert client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": session["token"], "question_ids": [remaining_question_id]},
).status_code == 200
second_batch = client.post(
f"/api/ask/tasks/{generated['task_id']}/submission",
data={"token": session["token"], "payee_name": "测试用户"},
files=[
("files", ("question-2.png", b"question-2", "image/png")),
("payment_file", ("payment-2.png", b"payment-2", "image/png")),
],
).json()["data"]
assert client.post(
f"/api/admin/ask-batch-reviews/{second_batch['batch_id']}/decision",
json={"approved": True},
).status_code == 200
assert client.post(
f"/api/admin/task-board/ask-batches/{second_batch['batch_id']}/confirm-refund",
files={"refund_file": ("refund-2.png", b"refund-2", "image/png")},
).status_code == 200
completed_board = task_board_items(client, task_type="ask", page_size=100)
completed_parent = next(
item for item in completed_board
if item.get("record_kind") == "ask_task" and item["id"] == generated["id"]
)
assert completed_parent["status_label"] == "已完成"
assert completed_parent["completed_count"] == 2
assert len(completed_parent["claimed_tasks"]) == 2
parent_detail = client.get(completed_parent["detail_endpoint"])
assert parent_detail.status_code == 200
assert {
question["user_id"]
for question in parent_detail.json()["data"]["selected_questions"]
} == {session["user_id"]}
completed_link = client.get(task_url)
assert completed_link.status_code == 409
assert completed_link.json()["detail"] == "本次提问任务已全部完成,可查看历史状态"
history = client.get(
f"/api/ask/me/assignments?token={session['token']}"
)
assert history.status_code == 200
completed_batches = [
item for item in history.json()["data"]
if item["task_id"] == generated["task_id"] and item["status"] == "已完成"
]
assert len(completed_batches) == 2
other_session = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
).json()["data"]
unrelated_user = client.get(
f"/api/ask/tasks/{generated['task_id']}?token={other_session['token']}"
)
assert unrelated_user.status_code == 404