- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py) stops deleted/renamed/re-worded content from being recreated and keeps admin-edited lesson text; new rows in the seed files still arrive (migration content.0010) - plan list cache is invalidated on Plan save (receiver used a stale 'v1' key) - token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE) - a failed gateway result only changes a pending order (forged NOK no longer overwrites refunded/canceled) - tutor gets the correct answer only after the student has answered - support reply notification moved to a TicketMessage post_save signal - global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000) - Bazaar verify uses only the order's SKU; bazaar orders require a SKU - /api/health warns on the committed signing key; deploy.sh saves local commits; 400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion - earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
13 KiB
Python
290 lines
13 KiB
Python
"""
|
||
تستهای `seed_content` — محتوایی که از ریپو روی سرور بالا میآید.
|
||
|
||
چیزی که اینجا محافظت میشود، سه شکستِ خاموش است: سوالی که به درسِ اشتباه
|
||
بچسبد چون idها بینِ دو دیتابیس یکی نیستند، دیپلویِ دوم که همهچیز را تکراری
|
||
کند، و ردیفِ نمونهای که از دیتابیسِ لوکالِ ما سر از production دربیاورد.
|
||
"""
|
||
import json
|
||
import tempfile
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
from django.core.management import call_command
|
||
from django.core.management.base import CommandError
|
||
from django.test import TestCase
|
||
|
||
from content.management.commands import seed_content
|
||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||
|
||
CURRICULUM = {
|
||
'grades': [{
|
||
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
|
||
'units': [{
|
||
'title': 'فصل ۴: توان و ریشه', 'order': 4,
|
||
'nodes': [{
|
||
'title': 'توان صحیح', 'order': 1,
|
||
'lessons': [
|
||
{'title': 'ضرب توانها', 'introText': 'پایهها را نگه دار.'},
|
||
{'title': 'تقسیم توانها', 'introText': ''},
|
||
],
|
||
}],
|
||
}],
|
||
}],
|
||
}
|
||
|
||
QUESTIONS = {
|
||
'questions': [{
|
||
'path': ['پایه نهم', 'فصل ۴: توان و ریشه', 'توان صحیح', 'تقسیم توانها'],
|
||
'prompt': 'حاصل ۲^۵ ÷ ۲^۳ چند است؟',
|
||
'type': 'multiple_choice',
|
||
'choices': ['۴', '۸', '۱۶'],
|
||
'correct': 0,
|
||
}],
|
||
}
|
||
|
||
|
||
class SeedContentTests(TestCase):
|
||
def setUp(self):
|
||
self.dir = Path(tempfile.mkdtemp())
|
||
self.curriculum_file = self.dir / 'curriculum.json'
|
||
self.questions_file = self.dir / 'questions.json'
|
||
self._write(CURRICULUM, QUESTIONS)
|
||
|
||
patches = [
|
||
mock.patch.object(seed_content, 'SEED_DIR', self.dir),
|
||
mock.patch.object(seed_content, 'CURRICULUM_FILE', self.curriculum_file),
|
||
mock.patch.object(seed_content, 'QUESTIONS_FILE', self.questions_file),
|
||
]
|
||
for patch in patches:
|
||
patch.start()
|
||
self.addCleanup(patch.stop)
|
||
|
||
def _write(self, curriculum, questions):
|
||
self.curriculum_file.write_text(
|
||
json.dumps(curriculum, ensure_ascii=False), encoding='utf-8')
|
||
self.questions_file.write_text(
|
||
json.dumps(questions, ensure_ascii=False), encoding='utf-8')
|
||
|
||
def test_loads_curriculum_and_questions_into_empty_database(self):
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Grade.objects.count(), 1)
|
||
self.assertEqual(Lesson.objects.count(), 2)
|
||
question = Question.objects.get()
|
||
self.assertEqual(question.lesson.title, 'تقسیم توانها')
|
||
self.assertEqual(question.choices.count(), 3)
|
||
self.assertEqual(question.choices.get(is_correct=True).value, '۴')
|
||
|
||
def test_second_run_changes_nothing(self):
|
||
"""دیپلویِ دوم نباید چیزی را تکراری کند — این کلِ ایمنیِ خودکار بودنش است."""
|
||
call_command('seed_content')
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Grade.objects.count(), 1)
|
||
self.assertEqual(Lesson.objects.count(), 2)
|
||
self.assertEqual(Question.objects.count(), 1)
|
||
self.assertEqual(Choice.objects.count(), 3)
|
||
|
||
def test_question_follows_the_title_path_not_the_id(self):
|
||
"""
|
||
بازتولیدِ همان تلهای که این فایل برایش ساخته شد: روی سرورِ خالی idها
|
||
از ۱ شروع میشوند، ولی لوکالِ ما idهای پراکنده دارد. اگر سوال با id
|
||
وصل میشد، اینجا به درسِ اشتباه میچسبید — بی هیچ خطایی.
|
||
"""
|
||
grade = Grade.objects.create(title='پایه دیگر', icon='', color='')
|
||
unit = Unit.objects.create(grade=grade, title='فصل پرت', order=1)
|
||
node = Node.objects.create(unit=unit, title='مبحث پرت', order=1)
|
||
for index in range(5):
|
||
Lesson.objects.create(node=node, title=f'درسِ پرت {index}')
|
||
|
||
call_command('seed_content')
|
||
|
||
question = Question.objects.get()
|
||
self.assertEqual(question.lesson.title, 'تقسیم توانها')
|
||
self.assertEqual(question.lesson.node.unit.grade.title, 'پایه نهم')
|
||
|
||
def test_unknown_path_fails_loudly_and_writes_nothing(self):
|
||
self._write(CURRICULUM, {'questions': [{
|
||
**QUESTIONS['questions'][0],
|
||
'path': ['پایه نهم', 'فصل ۴: توان و ریشه', 'توان صحیح', 'درسی که نیست'],
|
||
}]})
|
||
|
||
with self.assertRaises(CommandError):
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Grade.objects.count(), 0)
|
||
self.assertEqual(Question.objects.count(), 0)
|
||
|
||
def test_dry_run_reports_without_writing(self):
|
||
call_command('seed_content', '--dry-run')
|
||
|
||
self.assertEqual(Grade.objects.count(), 0)
|
||
self.assertEqual(Question.objects.count(), 0)
|
||
|
||
def test_editing_a_seeded_question_survives_the_next_deploy(self):
|
||
"""
|
||
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بدونِ اینکه دیپلویِ بعدی
|
||
نسخهی خودش را برگرداند یا کنارش یک تکراری بسازد.
|
||
"""
|
||
call_command('seed_content')
|
||
question = Question.objects.get()
|
||
question.choices.filter(is_correct=True).update(value='چهار')
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Question.objects.count(), 1)
|
||
self.assertEqual(question.choices.get(is_correct=True).value, 'چهار')
|
||
|
||
# ── کارِ ادمین با دیپلویِ بعدی برنمیگردد (۲۰۲۶-۱۰-۰۳، content/seed_sync.py) ──
|
||
|
||
def test_a_question_the_admin_deleted_stays_deleted(self):
|
||
call_command('seed_content')
|
||
Question.objects.get().delete()
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Question.objects.count(), 0)
|
||
|
||
def test_fixing_a_question_prompt_does_not_bring_the_old_one_back(self):
|
||
call_command('seed_content')
|
||
Question.objects.update(prompt='حاصل ۲^۵ ÷ ۲^۳ چند میشود؟')
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(list(Question.objects.values_list('prompt', flat=True)),
|
||
['حاصل ۲^۵ ÷ ۲^۳ چند میشود؟'])
|
||
|
||
def test_renaming_a_unit_does_not_duplicate_it(self):
|
||
call_command('seed_content')
|
||
Unit.objects.update(title='فصل ۴: توان')
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Unit.objects.count(), 1)
|
||
self.assertEqual(Lesson.objects.count(), 2)
|
||
self.assertEqual(Question.objects.count(), 1)
|
||
|
||
def test_lesson_text_edited_in_admin_is_kept(self):
|
||
call_command('seed_content')
|
||
Lesson.objects.filter(title='ضرب توانها').update(intro_text='متنِ ادمین')
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Lesson.objects.get(title='ضرب توانها').intro_text, 'متنِ ادمین')
|
||
|
||
def test_lesson_text_changed_in_the_file_still_arrives_when_untouched(self):
|
||
call_command('seed_content')
|
||
curriculum = json.loads(json.dumps(CURRICULUM))
|
||
curriculum['grades'][0]['units'][0]['nodes'][0]['lessons'][0]['introText'] = 'متنِ تازهی فایل'
|
||
self._write(curriculum, QUESTIONS)
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Lesson.objects.get(title='ضرب توانها').intro_text, 'متنِ تازهی فایل')
|
||
|
||
def test_new_content_in_the_file_still_arrives(self):
|
||
call_command('seed_content')
|
||
questions = {'questions': QUESTIONS['questions'] + [{
|
||
**QUESTIONS['questions'][0], 'prompt': 'سوالِ تازه', 'choices': ['۱', '۲', '۳'],
|
||
}]}
|
||
self._write(CURRICULUM, questions)
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Question.objects.count(), 2)
|
||
|
||
def test_export_leaves_out_demo_rows(self):
|
||
"""
|
||
[DEMO]/[SEED] در دیتابیسِ لوکال زندگی میکنند و نباید در فایلی بنشینند
|
||
که مستقیم روی production لود میشود.
|
||
"""
|
||
demo = Grade.objects.create(title='[DEMO] پایهٔ نمونه', icon='', color='')
|
||
demo_unit = Unit.objects.create(grade=demo, title='واحد ۱ — جمع', order=1)
|
||
demo_node = Node.objects.create(unit=demo_unit, title='جمع تا ۱۰', order=1)
|
||
demo_lesson = Lesson.objects.create(node=demo_node, title='گرمکردن')
|
||
demo_question = Question.objects.create(
|
||
lesson=demo_lesson, type='multiple_choice', prompt='۲ + ۳ = ?')
|
||
Choice.objects.create(question=demo_question, value='۵', is_correct=True)
|
||
Choice.objects.create(question=demo_question, value='۴', is_correct=False)
|
||
call_command('seed_content') # یک پایهی واقعی هم وجود داشته باشد
|
||
|
||
call_command('seed_content', '--export')
|
||
|
||
written = json.loads(self.curriculum_file.read_text(encoding='utf-8'))
|
||
titles = [grade['title'] for grade in written['grades']]
|
||
self.assertEqual(titles, ['پایه نهم'])
|
||
rows = json.loads(self.questions_file.read_text(encoding='utf-8'))['questions']
|
||
self.assertTrue(all(row['path'][0] == 'پایه نهم' for row in rows))
|
||
|
||
def test_export_then_load_round_trips(self):
|
||
call_command('seed_content')
|
||
call_command('seed_content', '--export')
|
||
Question.objects.all().delete()
|
||
Grade.objects.all().delete()
|
||
|
||
call_command('seed_content')
|
||
|
||
self.assertEqual(Lesson.objects.count(), 2)
|
||
self.assertEqual(Question.objects.get().lesson.title, 'تقسیم توانها')
|
||
|
||
|
||
class SeedFilesInRepoTests(TestCase):
|
||
"""فایلهایی که واقعاً کامیت شدهاند باید سالم و بیردیفِ نمونه باشند."""
|
||
|
||
def test_committed_curriculum_parses_and_has_no_demo_rows(self):
|
||
from content import curriculum_io
|
||
|
||
grades = curriculum_io.parse(seed_content.CURRICULUM_FILE.read_bytes())
|
||
|
||
self.assertTrue(grades)
|
||
for grade in grades:
|
||
self.assertFalse(grade['title'].startswith(('[DEMO]', '[SEED]')))
|
||
|
||
def test_committed_questions_all_point_at_a_real_lesson(self):
|
||
from content import question_io
|
||
|
||
rows = json.loads(
|
||
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
|
||
if not rows:
|
||
self.skipTest('هنوز سوالِ واقعیای در ریپو نیست.')
|
||
|
||
call_command('seed_content')
|
||
question_io.resolve_paths(rows) # مسیرِ حلنشده خطا میدهد
|
||
|
||
def test_committed_questions_are_well_formed(self):
|
||
rows = json.loads(
|
||
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
|
||
if not rows:
|
||
self.skipTest('هنوز سوالِ واقعیای در ریپو نیست.')
|
||
|
||
for index, row in enumerate(rows):
|
||
where = f'سوال {index + 1}'
|
||
self.assertTrue(row['prompt'].strip(), where)
|
||
self.assertEqual(len(row['choices']), 4, where)
|
||
self.assertEqual(len(set(row['choices'])), 4, where)
|
||
self.assertIn(row['correct'], range(4), where)
|
||
|
||
def test_every_topic_has_at_least_one_question(self):
|
||
"""
|
||
هدفِ این مجموعه «نمونه در چند جا» نیست: هر مبحث باید دستِکم یک سوال
|
||
داشته باشد، وگرنه ادمین در مرورگرِ محتوا به ستونهای خالی میرسد و
|
||
نمیداند محتوا نیست یا چیزی خراب است.
|
||
"""
|
||
call_command('seed_content')
|
||
|
||
empty = Node.objects.filter(lessons__questions__isnull=True).distinct()
|
||
empty = [node for node in empty if not Question.objects.filter(
|
||
lesson__node=node).exists()]
|
||
self.assertEqual(empty, [], f'{len(empty)} مبحث بدونِ سوال ماند.')
|
||
|
||
def test_the_right_answer_is_not_always_the_first_choice(self):
|
||
"""جوابِ درست اگر همیشه گزینهٔ اول باشد، آزمون سنجشِ چیزی نیست."""
|
||
rows = json.loads(
|
||
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
|
||
if len(rows) < 10:
|
||
self.skipTest('برای این بررسی سوالِ کافی نیست.')
|
||
|
||
positions = {row['correct'] for row in rows}
|
||
self.assertGreater(len(positions), 2)
|