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>
146 lines
7.6 KiB
Python
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" onfocus="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')
|