From 7316b4c8b66adf9de350384e2b03eb1646116322 Mon Sep 17 00:00:00 2001 From: dynastxu <151742029+ETOofficial@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:10:24 +0800 Subject: [PATCH] Squashed 'src/DjangoBlog/' changes from b99778c..cafdade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cafdade test(blog): 添加文章点赞功能的集成测试 4a7f5b6 feat(blog): 添加文章点赞功能及单元测试 8ab57b6 fix(blog): 防止文章点赞按钮重复点击 a6807a3 feat(blog): 更新文章点赞功能的按钮显示逻辑 f12356e refactor(blog): 将变量声明从var改为const 60442eb feat(blog): 添加文章点赞功能 git-subtree-dir: src/DjangoBlog git-subtree-split: cafdade94cf2c6c2ec89a1ac8194eb8d1b30e1e3 --- blog/migrations/0007_article_users_like.py | 20 ++ blog/models.py | 6 + blog/tests.py | 285 +++++++++++++++++++++ blog/urls.py | 4 + blog/views.py | 42 ++- djangoblog/settings.py | 38 +-- templates/blog/article_detail.html | 61 +++++ 7 files changed, 440 insertions(+), 16 deletions(-) create mode 100644 blog/migrations/0007_article_users_like.py diff --git a/blog/migrations/0007_article_users_like.py b/blog/migrations/0007_article_users_like.py new file mode 100644 index 0000000..b5ccf80 --- /dev/null +++ b/blog/migrations/0007_article_users_like.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.7 on 2025-11-13 13:53 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0006_alter_blogsettings_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='users_like', + field=models.ManyToManyField(blank=True, related_name='articles_liked', to=settings.AUTH_USER_MODEL, verbose_name='点赞用户'), + ), + ] diff --git a/blog/models.py b/blog/models.py index 71c3b4f..faeb851 100644 --- a/blog/models.py +++ b/blog/models.py @@ -147,6 +147,12 @@ class Article(BaseModel): null=False) # bjy: 标签,多对多关联到Tag模型 tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) + users_like = models.ManyToManyField( + settings.AUTH_USER_MODEL, # 关联到用户模型 + related_name='articles_liked', # 反向关系名称,user.articles_liked.all()可获取用户点赞的所有文章 + blank=True, # 允许文章没有被任何用户点赞 + verbose_name='点赞用户' # 在Admin后台显示的字段名称 + ) # bjy: 将body字段转换为字符串 def body_to_string(self): diff --git a/blog/tests.py b/blog/tests.py index 57b07e3..a7548b0 100644 --- a/blog/tests.py +++ b/blog/tests.py @@ -1,8 +1,11 @@ # bjy: 导入操作系统接口模块 +import json import os +from unittest.mock import patch, MagicMock # bjy: 从Django中导入设置、文件上传、命令调用、分页器、静态文件、测试工具、URL反向解析和时区工具 from django.conf import settings +from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command from django.core.paginator import Paginator @@ -16,6 +19,7 @@ from accounts.models import BlogUser from blog.forms import BlogSearchForm from blog.models import Article, Category, Tag, SideBar, Links from blog.templatetags.blog_tags import load_pagination_info, load_articletags +from blog.views import LikeArticle from djangoblog.utils import get_current_site, get_sha256 # bjy: 从项目中导入OAuth相关模型 from oauth.models import OAuthUser, OAuthConfig @@ -290,3 +294,284 @@ class ArticleTest(TestCase): call_command("clear_cache") call_command("sync_user_avatar") call_command("build_search_words") + + +class TestLikeArticle(TestCase): + """测试 LikeArticle 视图类中的 post 方法""" + + def setUp(self): + """ + 初始化测试所需的数据和工具 + """ + self.factory = RequestFactory() + self.user = BlogUser.objects.create_user(username='testuser', password='password') + # 创建分类(Article模型需要category字段) + self.category = Category.objects.create( + name="Test Category", + slug="test-category" + ) + self.article = Article.objects.create( + title="Test Article", + body="This is a test article.", + author=self.user, + category=self.category, # Article模型必需字段 + views=0, + ) + + @patch('blog.models.Article.objects.get') + def test_post_like_article_successfully(self, mock_get_article): + """ + 测试场景:用户第一次点赞文章成功 + + 输入: + - 已登录用户 + - 存在的文章 ID + - 用户尚未点赞该文章 + + 输出: + - type = 1 表示新增点赞 + - like_sum 更新为 1 + - state = 200 成功状态码 + """ + # 设置 mock 返回值 + mock_article = MagicMock() + mock_article.users_like.filter.return_value.exists.return_value = False + mock_article.users_like.count.return_value = 1 + mock_get_article.return_value = mock_article + + # 构造 POST 请求 + request = self.factory.post('/like/', {'article_id': str(self.article.id)}) + request.user = self.user + + # 执行被测函数 + response = LikeArticle().post(request) + + # 断言调用了 add 方法表示点赞 + mock_article.users_like.add.assert_called_once_with(self.user) + mock_article.users_like.remove.assert_not_called() + + # 解析响应内容 + content = json.loads(response.content.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(content['type'], 1) + self.assertEqual(content['like_sum'], 1) + self.assertEqual(content['state'], 200) + + @patch('blog.models.Article.objects.get') + def test_post_unlike_article_successfully(self, mock_get_article): + """ + 测试场景:用户取消点赞文章成功 + + 输入: + - 已登录用户 + - 存在的文章 ID + - 用户已经点赞了该文章 + + 输出: + - type = 0 表示取消点赞 + - like_sum 更新为 0 + - state = 200 成功状态码 + """ + # 设置 mock 返回值 + mock_article = MagicMock() + mock_article.users_like.filter.return_value.exists.return_value = True + mock_article.users_like.count.return_value = 0 + mock_get_article.return_value = mock_article + + # 构造 POST 请求 + request = self.factory.post('/like/', {'article_id': str(self.article.id)}) + request.user = self.user + + # 执行被测函数 + response = LikeArticle().post(request) + + # 断言调用了 remove 方法表示取消点赞 + mock_article.users_like.remove.assert_called_once_with(self.user) + mock_article.users_like.add.assert_not_called() + + # 解析响应内容 + content = json.loads(response.content.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(content['type'], 0) + self.assertEqual(content['like_sum'], 0) + self.assertEqual(content['state'], 200) + + @patch('blog.models.Article.objects.get') + def test_post_article_does_not_exist(self, mock_get_article): + """ + 测试场景:提供的文章 ID 不存在 + + 输入: + - 任意用户 + - 不存在的文章 ID + + 输出: + - state = 400 错误状态码 + - data 包含“文章不存在”提示 + """ + # 设置 mock 抛出 DoesNotExist 异常 + mock_get_article.side_effect = Article.DoesNotExist + + # 构造 POST 请求 + request = self.factory.post('/like/', {'article_id': '999'}) + request.user = self.user + + # 执行被测函数 + response = LikeArticle().post(request) + + # 解析响应内容 + content = json.loads(response.content.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(content['state'], 400) + self.assertIn("文章不存在", content['data']) + + @patch('blog.models.Article.objects.get') + def test_post_internal_server_error(self, mock_get_article): + """ + 测试场景:系统内部发生异常 + + 输入: + - 任意用户 + - 导致异常的操作(如数据库连接失败等) + + 输出: + - state = 500 错误状态码 + - data 包含具体异常描述 + """ + # 设置 mock 抛出通用异常 + mock_get_article.side_effect = Exception("数据库连接超时") + + # 构造 POST 请求 + request = self.factory.post('/like/', {'article_id': str(self.article.id)}) + request.user = self.user + + # 执行被测函数 + response = LikeArticle().post(request) + + # 解析响应内容 + content = json.loads(response.content.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(content['state'], 500) + self.assertIn("服务器错误", content['data']) + + +class LikeIntegrationTests(TestCase): + def setUp(self): + """设置测试数据""" + self.client = Client() + self.user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123' + ) + self.other_user = BlogUser.objects.create_user( + username='otheruser', + email='other@example.com', + password='testpass123' + ) + + # 创建分类(因为Article模型需要category字段) + self.category = Category.objects.create( + name='测试分类', + slug='test-category' + ) + + self.article = Article.objects.create( + title='Test Article', + body='Test content', + author=self.user, + category=self.category, # 必须提供category + # 其他必填字段使用默认值 + status='p', # 发布状态 + comment_status='o', # 开放评论 + type='a', # 文章类型 + article_order=0, + show_toc=False + ) + + def test_like_workflow(self): + """测试完整的点赞流程""" + # 1. 用户登录 + self.client.login(username='testuser', password='testpass123') + + # 2. 发送点赞请求 + response = self.client.post( + reverse('blog:like_article'), + {'article_id': self.article.id}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + + # 3. 验证响应 + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['type'], 1) # 点赞操作 + + # 4. 验证数据库状态 + self.assertTrue(self.article.users_like.filter(id=self.user.id).exists()) + self.assertEqual(self.article.users_like.count(), 1) + + # 5. 测试取消点赞 + response = self.client.post( + reverse('blog:like_article'), + {'article_id': self.article.id}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + + # 6. 验证取消点赞 + self.assertEqual(response.json()['type'], 0) # 取消点赞操作 + self.assertFalse(self.article.users_like.filter(id=self.user.id).exists()) + self.assertEqual(self.article.users_like.count(), 0) + + def test_multiple_users_liking(self): + """测试多个用户点赞同一篇文章""" + # 第一个用户点赞 + self.client.login(username='testuser', password='testpass123') + self.client.post( + reverse('blog:like_article'), + {'article_id': self.article.id}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + + # 第二个用户点赞 + self.client.login(username='otheruser', password='testpass123') + response = self.client.post( + reverse('blog:like_article'), + {'article_id': self.article.id}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + + # 验证两个用户都点赞成功 + self.assertEqual(self.article.users_like.count(), 2) + self.assertTrue(self.article.users_like.filter(id=self.user.id).exists()) + self.assertTrue(self.article.users_like.filter(id=self.other_user.id).exists()) + + def test_like_nonexistent_article(self): + """测试给不存在的文章点赞""" + self.client.login(username='testuser', password='testpass123') + + response = self.client.post( + reverse('blog:like_article'), + {'article_id': 1145}, # 不存在的文章ID + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + + self.assertEqual(response.status_code, 400) + self.assertIn('文章不存在', response.json()['data']) + + def test_like_without_login(self): + """测试未登录用户点赞""" + response = self.client.post( + reverse('blog:like_article'), + {'article_id': self.article.id}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) + + # 应该重定向到登录页面或者返回错误 + self.assertIn(response.status_code, [302, 403]) # 重定向或权限拒绝 + + def test_like_with_invalid_method(self): + """测试使用错误的HTTP方法""" + self.client.login(username='testuser', password='testpass123') + + response = self.client.get(reverse('blog:like_article')) # 使用GET而不是POST + + self.assertEqual(response.status_code, 405) # Method Not Allowed diff --git a/blog/urls.py b/blog/urls.py index 42235ef..76dff4a 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -76,4 +76,8 @@ urlpatterns = [ r'clean', views.clean_cache_view, name='clean'), + path( + 'like_article/', + views.LikeArticle.as_view(), + name='like_article'), ] diff --git a/blog/views.py b/blog/views.py index 617b99c..67565ad 100644 --- a/blog/views.py +++ b/blog/views.py @@ -5,13 +5,16 @@ import uuid # bjy: 从Django中导入设置、分页器、HTTP响应、快捷函数、静态文件、时区、国际化、CSRF豁免和基于类的视图 from django.conf import settings +from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator -from django.http import HttpResponse, HttpResponseForbidden +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404 from django.shortcuts import render from django.templatetags.static import static from django.utils import timezone +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ +from django.views import View from django.views.decorators.csrf import csrf_exempt from django.views.generic.detail import DetailView from django.views.generic.list import ListView @@ -360,6 +363,43 @@ class EsSearchView(SearchView): return context +class LikeArticle(View): + """ + 处理文章点赞和取消点赞 + """ + + @method_decorator(login_required) # 确保只有登录用户才能点赞 + def post(self, request): + try: + user = request.user + article_id = request.POST.get('article_id') # 获取文章ID + article = Article.objects.get(id=article_id) # 获取文章对象 + + # 检查当前用户是否已经为这篇文章点过赞 + if article.users_like.filter(id=user.id).exists(): + # 如果点过赞,则取消点赞 (从多对多关系中移除) + article.users_like.remove(user) + action_type = 0 # 0代表取消点赞 + else: + # 如果没点过赞,则添加点赞 (添加到多对多关系) + article.users_like.add(user) + action_type = 1 # 1代表点赞 + + # 获取更新后的点赞总数 + like_count = article.users_like.count() + + # 返回JSON数据给前端 + return JsonResponse({ + 'state': 200, + 'type': action_type, + 'like_sum': like_count + }) + + except Article.DoesNotExist: + return JsonResponse({'state': 400, 'data': '文章不存在'}) + except Exception as e: + return JsonResponse({'state': 500, 'data': f'服务器错误: {e}'}) + # bjy: 文件上传视图,使用csrf_exempt豁免CSRF验证 @csrf_exempt def fileupload(request): diff --git a/djangoblog/settings.py b/djangoblog/settings.py index dfeaa74..8f20cde 100644 --- a/djangoblog/settings.py +++ b/djangoblog/settings.py @@ -122,21 +122,29 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application' # https://docs.djangoproject.com/en/1.10/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': env('DJANGO_MYSQL_DATABASE'), - 'USER': env('DJANGO_MYSQL_USER'), - 'PASSWORD': env('DJANGO_MYSQL_PASSWORD'), - 'HOST': env('DJANGO_MYSQL_HOST'), - 'PORT': int( - env('DJANGO_MYSQL_PORT')), - 'OPTIONS': { - 'charset': 'utf8mb4', - 'ssl_mode': 'VERIFY_IDENTITY', - 'ssl': {'ca': env('DJANGO_MYSQL_SSL_CA')} - }, - }} +if TESTING: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': env('DJANGO_MYSQL_DATABASE'), + 'USER': env('DJANGO_MYSQL_USER'), + 'PASSWORD': env('DJANGO_MYSQL_PASSWORD'), + 'HOST': env('DJANGO_MYSQL_HOST'), + 'PORT': int( + env('DJANGO_MYSQL_PORT')), + 'OPTIONS': { + 'charset': 'utf8mb4', + 'ssl_mode': 'VERIFY_IDENTITY', + 'ssl': {'ca': env('DJANGO_MYSQL_SSL_CA')} + }, + }} # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators diff --git a/templates/blog/article_detail.html b/templates/blog/article_detail.html index a74a0db..1196340 100755 --- a/templates/blog/article_detail.html +++ b/templates/blog/article_detail.html @@ -8,6 +8,16 @@