"""Per-section notes and feedback on an article — two things, kept apart. Disposable SQLite. A note is the writer's and nobody else's; feedback goes to whoever maintains the article. The case that matters most is what happens to writing whose section has gone: it is kept, marked, and still says which heading it was written under. """ import unittest import test_quiz_builder as fixtures from app.models.article import Article from app.models.category_grant import CategoryGrant from app.models.feedback import ArticleFeedback from app.models.user_note import ArticleSectionNote from app.routers import articles, feedback SECTION_A = "a" * 32 SECTION_B = "b" * 32 class ArticleNoteTests(unittest.TestCase): def setUp(self): self.bank = fixtures.BuilderTests() self.bank.setUp() self.client = self.bank.client self.db = self.bank.db self.client.app.include_router(articles.router, prefix="/articles") self.client.app.include_router(feedback.router, prefix="/feedback") self.article = Article( id=50, slug="kawasaki", title="Kawasaki disease", status="published", category_id=2, user_id=self.bank.mod.id, sections=[ {"id": SECTION_A, "slug": "diagnosis", "title": "Diagnosis", "content": "Fever for five days.", "variant": "long"}, {"id": SECTION_B, "slug": "management", "title": "Management", "content": "IVIG.", "variant": "long"}, ]) self.db.add(self.article) self.db.commit() self.bank.user = self.bank.owner def tearDown(self): self.bank.tearDown() def write(self, section_id=SECTION_A, content="IVIG within ten days."): return self.client.put(f"/articles/50/notes/{section_id}", json={"content": content}) def drop_section(self, section_id=SECTION_A): """What saving an article without that section leaves behind.""" self.article.sections = [sec for sec in self.article.sections if sec["id"] != section_id] self.db.add(self.article) self.db.commit() # ── the private note ────────────────────────────────────────────────────── def test_a_note_is_written_read_back_and_emptied(self): self.assertEqual(self.write().status_code, 200) notes = self.client.get("/articles/50/notes").json() self.assertEqual(len(notes), 1) self.assertEqual(notes[0]["section_id"], SECTION_A) self.assertEqual(notes[0]["section_title"], "Diagnosis") self.assertFalse(notes[0]["orphaned"]) # Empty means gone, the same as a question note. self.assertEqual(self.write(content=" ").status_code, 200) self.assertEqual(self.client.get("/articles/50/notes").json(), []) def test_a_note_is_nobody_elses_business(self): self.write() self.bank.user = self.bank.peer self.assertEqual(self.client.get("/articles/50/notes").json(), []) # Not even a moderator, who maintains the article, reads it. self.bank.user = self.bank.mod self.assertEqual(self.client.get("/articles/50/notes").json(), []) def test_two_readers_keep_separate_notes_on_one_section(self): self.write(content="Mine") self.bank.user = self.bank.peer self.write(content="Theirs") self.assertEqual(self.client.get("/articles/50/notes").json()[0]["content"], "Theirs") self.bank.user = self.bank.owner self.assertEqual(self.client.get("/articles/50/notes").json()[0]["content"], "Mine") def test_a_note_on_a_section_that_does_not_exist_is_refused(self): self.assertEqual(self.write(section_id="c" * 32).status_code, 404) # ── what happens to writing when a section changes ──────────────────────── def test_renaming_a_section_keeps_the_note_attached(self): self.write() # Reassigned rather than mutated in place: a JSON column does not # notice an edit inside the list, which is also why the editor rewrites # the whole array on save. self.article.sections = [{**self.article.sections[0], "title": "Making the diagnosis"}, self.article.sections[1]] self.db.add(self.article) self.db.commit() note = self.client.get("/articles/50/notes").json()[0] # The id is what the note points at, so a retitled heading is still the # same section — the note follows the rename rather than orphaning. self.assertFalse(note["orphaned"]) self.assertEqual(note["section_title"], "Making the diagnosis") def test_a_deleted_section_keeps_the_note_and_says_where_it_came_from(self): self.write() self.drop_section() note = self.client.get("/articles/50/notes").json()[0] self.assertTrue(note["orphaned"]) self.assertEqual(note["content"], "IVIG within ten days.") # Losing somebody's writing silently is the outcome being engineered # against; the heading it was written under is what makes it readable. self.assertEqual(note["section_title"], "Diagnosis") self.assertEqual(self.db.query(ArticleSectionNote).count(), 1) def test_unpublishing_an_article_does_not_touch_its_notes(self): """The section index is dropped on unpublish; the notes are not keyed to it.""" self.write() self.bank.user = self.bank.mod self.article.status = "draft" self.db.add(self.article) self.db.commit() self.bank.user = self.bank.owner # A learner cannot read an unpublished article, so the note is out of # reach — but it is still there, and comes back when the article does. self.assertEqual(self.client.get("/articles/50/notes").status_code, 404) self.assertEqual(self.db.query(ArticleSectionNote).count(), 1) self.bank.user = self.bank.mod self.article.status = "published" self.db.add(self.article) self.db.commit() self.bank.user = self.bank.owner self.assertEqual(len(self.client.get("/articles/50/notes").json()), 1) def test_the_writer_can_delete_their_own_orphan(self): self.write() self.drop_section() self.assertEqual(self.client.delete(f"/articles/50/notes/{SECTION_A}").status_code, 204) self.assertEqual(self.db.query(ArticleSectionNote).count(), 0) # ── feedback, which is the other thing entirely ─────────────────────────── def test_feedback_on_a_section_reaches_whoever_maintains_the_article(self): sent = self.client.post("/feedback/articles/50", json={"message": "The IVIG dose is wrong.", "section_id": SECTION_B}) self.assertEqual(sent.status_code, 201, sent.text) self.bank.user = self.bank.mod rows = self.client.get("/feedback/articles/50").json() self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["section_title"], "Management") self.assertEqual(rows[0]["href"], f"/articles/50?section={SECTION_B}") self.assertFalse(rows[0]["section_gone"]) def test_feedback_and_a_note_are_not_the_same_row(self): self.write(section_id=SECTION_B, content="Private thought") self.client.post("/feedback/articles/50", json={"message": "Typo here.", "section_id": SECTION_B}) self.bank.user = self.bank.mod reported = self.client.get("/feedback/articles/50").json() self.assertEqual([row["message"] for row in reported], ["Typo here."]) # The private note is nowhere in what the maintainer sees. self.assertNotIn("Private thought", str(reported)) def test_a_report_survives_the_section_it_was_about(self): self.client.post("/feedback/articles/50", json={"message": "Wrong.", "section_id": SECTION_A}) self.drop_section() self.bank.user = self.bank.mod row = self.client.get("/feedback/articles/50").json()[0] self.assertTrue(row["section_gone"]) self.assertEqual(row["section_title"], "Diagnosis") def test_only_the_maintainer_reads_and_answers_article_feedback(self): self.client.post("/feedback/articles/50", json={"message": "Wrong."}) row_id = self.db.query(ArticleFeedback).one().id self.bank.user = self.bank.peer self.assertEqual(self.client.get("/feedback/articles/50").status_code, 403) self.assertEqual(self.client.patch(f"/feedback/articles/reports/{row_id}", json={"status": "resolved"}).status_code, 403) # A grant over the branch the article is filed in is enough. self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id)) self.db.commit() self.assertEqual(self.client.get("/feedback/articles/50").status_code, 200) def test_a_reply_closes_the_thread_and_the_reader_can_see_it(self): self.client.post("/feedback/articles/50", json={"message": "Wrong.", "section_id": SECTION_A}) row_id = self.db.query(ArticleFeedback).one().id self.bank.user = self.bank.mod answered = self.client.patch(f"/feedback/articles/reports/{row_id}", json={"reply": "Rewritten.", "status": "resolved"}) self.assertEqual(answered.status_code, 200, answered.text) self.bank.user = self.bank.owner mine = self.client.get("/feedback/articles/50/mine").json() self.assertEqual((mine[0]["reply"], mine[0]["status"]), ("Rewritten.", "resolved")) def test_article_reports_share_the_educator_badge(self): self.client.post("/feedback/articles/50", json={"message": "Wrong.", "section_id": SECTION_A}) self.bank.user = self.bank.mod body = self.client.get("/feedback/open").json() self.assertEqual(body["open"], 1) self.assertEqual(body["items"][0]["kind"], "article") def test_feedback_on_a_section_that_does_not_exist_is_refused(self): self.assertEqual(self.client.post( "/feedback/articles/50", json={"message": "Wrong.", "section_id": "z" * 32}).status_code, 404) self.assertEqual(self.client.post( "/feedback/articles/999", json={"message": "Wrong."}).status_code, 404) if __name__ == "__main__": unittest.main()