diora-web/podcasts/test_article.py
mrwnslz bd09d81366 Smarte Transkription D: Artikel-Endpunkt und Blog-Anbindung
POST/DELETE /podcasts/episodes/<id>/article/ (podcasts/article.py): legt die Quelle Podcast-Artikel an und erzeugt oder ersetzt das Item der Folge; Sanitizer behält href=#t=<Sekunden>; _item_data und Item-Ansicht liefern episode_id; feeds.js: Folge anhören und Zeitmarken-Links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:36:34 +01:00

146 lines
7.6 KiB
Python

import json
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from feeds.models import Item, Source
from .models import EpisodeArticle, PodcastEpisode, PodcastFeed, TranscriptionJob
BODY = ('<p>Zusammenfassung <script>alert(1)</script></p><h2>Teil eins</h2>'
'<p><a href="#t=754" onclick="evil()">▶ ab 12:34</a> und <a href="https://example.org/x">Quelle</a></p>')
class ArticleTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://p.example/rss', title='Salon',
author='Anna Autorin', artwork_url='https://p.example/feed.jpg')
self.ep = PodcastEpisode.objects.create(feed=self.feed, guid='e1', title='Folge 1',
audio_url='https://p.example/e1.mp3', artwork_url='https://p.example/e1.jpg')
def url(self, ep=None):
return f'/podcasts/episodes/{(ep or self.ep).id}/article/'
def post(self, data=None, ep=None):
data = {'title': 'Ein Artikel', 'summary': 'Kurz.', 'body_html': BODY, 'model': 'claude', 'kind': 'Interview'} \
if data is None else data
return self.client.post(self.url(ep), json.dumps(data), content_type='application/json')
def test_requires_login_and_ownership(self):
self.client.logout()
self.assertEqual(self.post().status_code, 401)
self.assertEqual(self.client.delete(self.url()).status_code, 401)
bob = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(bob)
self.assertEqual(self.post().status_code, 404)
self.assertEqual(self.client.delete(self.url()).status_code, 404)
self.assertFalse(Item.objects.exists())
def test_create(self):
r = self.post()
self.assertEqual(r.status_code, 200)
item = Item.objects.get(pk=r.json()['item_id'])
self.assertEqual(item.user, self.user)
self.assertEqual(item.kind, Source.BLOG)
self.assertEqual(item.title, 'Ein Artikel')
self.assertEqual(item.summary, 'Kurz.')
self.assertEqual(item.author, 'Anna Autorin')
self.assertEqual(item.image_url, 'https://p.example/e1.jpg')
self.assertEqual(item.url, 'https://p.example/e1.mp3')
self.assertTrue(item.content_full)
self.assertGreater(item.words, 3)
self.assertLess((timezone.now() - item.published).total_seconds(), 60)
art = EpisodeArticle.objects.get(episode=self.ep)
self.assertEqual((art.item, art.model, art.kind), (item, 'claude', 'Interview'))
self.assertEqual(item.episode_article, art)
def test_image_falls_back_to_feed_artwork(self):
self.ep.artwork_url = ''
self.ep.save()
item = Item.objects.get(pk=self.post().json()['item_id'])
self.assertEqual(item.image_url, 'https://p.example/feed.jpg')
def test_sanitizer_keeps_time_links_only(self):
item = Item.objects.get(pk=self.post().json()['item_id'])
self.assertNotIn('<script', item.content)
self.assertNotIn('alert(1)', item.content)
self.assertNotIn('onclick', item.content)
self.assertIn('href="#t=754"', item.content)
self.assertIn('href="https://example.org/x"', item.content)
evil = self.post({'title': 'x', 'body_html': '<a href="#t=1&quot; onfocus=&quot;x">a</a><a href="#top">b</a>'})
content = Item.objects.get(pk=evil.json()['item_id']).content
self.assertNotIn('onfocus', content)
self.assertNotIn('#top', content)
def test_source_made_once(self):
other = PodcastEpisode.objects.create(feed=self.feed, guid='e2', title='Folge 2', audio_url='https://p.example/e2.mp3')
self.post()
self.post(ep=other)
src = Source.objects.get(user=self.user, url='diora://podcast-artikel')
self.assertEqual((src.title, src.kind, src.fav, src.synthetic), ('Podcast-Artikel', Source.BLOG, True, True))
self.assertEqual(Source.objects.count(), 1)
self.assertEqual(src.items.count(), 2)
def test_replace_keeps_one_item(self):
first = self.post().json()['item_id']
Item.objects.filter(pk=first).update(read_at=timezone.now(), progress=1)
second = self.post({'title': 'Neu', 'body_html': '<p>Ganz anderer Text</p>', 'model': 'm2'}).json()['item_id']
self.assertEqual(first, second)
self.assertEqual(Item.objects.count(), 1)
item = Item.objects.get()
self.assertEqual((item.title, item.read_at, item.progress), ('Neu', None, 0))
self.assertIn('Ganz anderer Text', item.content)
self.assertEqual(EpisodeArticle.objects.get().model, 'm2')
def test_delete(self):
self.post()
self.assertEqual(self.client.delete(self.url()).status_code, 200)
self.assertFalse(Item.objects.exists())
self.assertFalse(EpisodeArticle.objects.exists())
self.assertEqual(Source.objects.count(), 1)
self.assertEqual(self.client.delete(self.url()).status_code, 200) # again: nothing to do
def test_validation(self):
for data in ({}, {'title': '', 'body_html': BODY}, {'title': 'x' * 301, 'body_html': BODY},
{'title': 'x'}, {'title': 'x', 'body_html': '<script>1</script>'},
{'title': 'x', 'body_html': 'a' * 1_000_001},
{'title': 'x', 'body_html': BODY, 'model': 'm' * 101}):
self.assertEqual(self.post(data).status_code, 400, data and list(data))
self.assertEqual(self.client.post(self.url(), 'nope', content_type='application/json').status_code, 400)
self.assertFalse(Item.objects.exists())
self.assertEqual(self.client.get(self.url()).status_code, 405)
def test_saving_clears_the_wish(self):
TranscriptionJob.objects.create(episode=self.ep, state='done', want_article=True, claimed_by='phone',
claimed_until=timezone.now() + timezone.timedelta(minutes=10))
self.post()
job = TranscriptionJob.objects.get()
self.assertEqual((job.want_article, job.claimed_by, job.claimed_until), (False, '', None))
def test_in_favourite_blogs_with_episode_id(self):
item_id = self.post().json()['item_id']
items = self.client.get('/feeds/blogs/?fav=1').json()['items']
self.assertEqual([i['id'] for i in items], [item_id])
self.assertEqual(items[0]['episode_id'], self.ep.id)
detail = self.client.get(f'/feeds/items/{item_id}/').json()['item']
self.assertEqual(detail['episode_id'], self.ep.id)
self.assertIn('href="#t=754"', detail['content'])
def test_ordinary_items_have_no_episode_id(self):
src = Source.objects.create(user=self.user, url='https://b.example/feed', title='B', kind=Source.BLOG, fav=True)
Item.objects.create(source=src, user=self.user, kind=Source.BLOG, guid_hash='g', title='T',
published=timezone.now(), content='<p>x</p>', content_full=True)
self.post()
items = self.client.get('/feeds/blogs/?fav=1').json()['items']
self.assertEqual(len(items), 2)
self.assertEqual(sorted('episode_id' in i for i in items), [False, True])
def test_blog_list_needs_no_query_per_article(self):
for n in range(5):
self.post(ep=PodcastEpisode.objects.create(feed=self.feed, guid=f'x{n}', title=str(n),
audio_url='https://p.example/x.mp3'))
with self.assertNumQueries(3):
self.client.get('/feeds/blogs/?fav=1')