You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
258 lines
11 KiB
258 lines
11 KiB
from django.test import TestCase
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.contrib.auth import get_user_model
|
|
from rest_framework.test import APIClient
|
|
from rest_framework import status
|
|
|
|
from apps.projects.models.project import (
|
|
Project,
|
|
KanbanColumn,
|
|
KanbanTask,
|
|
ProjectDocument,
|
|
)
|
|
from apps.profiles.models.institution import Institution, InstitutionMember
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class Phase5ProjectsTests(TestCase):
|
|
"""
|
|
Automated test suite for Phase 5:
|
|
- Project Creation & Default Kanban Column Generation
|
|
- Scoped Project Listing & Filtering (institution, category, status, search)
|
|
- Project Detail View with Multi-Institution Collaborators
|
|
- Drag-and-Drop Kanban Board View & Task Hierarchy
|
|
- Task Creation, Priority, Column Migration & Reordering
|
|
- Project Updates & Role-Based Permissions
|
|
- Project Document Uploads & Deletions
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
|
|
# Users
|
|
self.lead_admin = User.objects.create_user(
|
|
email="lead.director@mashhadcenter.org",
|
|
password="SecurePassword123!",
|
|
fullname="Hassan Razavi Director",
|
|
user_type=User.UserType.INSTITUTION_ADMIN,
|
|
country="Iran",
|
|
city="Mashhad"
|
|
)
|
|
self.collab_editor = User.objects.create_user(
|
|
email="collab.editor@berlincenter.de",
|
|
password="SecurePassword123!",
|
|
fullname="Fatima Berlin Coordinator",
|
|
user_type=User.UserType.EDITOR,
|
|
country="Germany",
|
|
city="Berlin"
|
|
)
|
|
self.unrelated_user = User.objects.create_user(
|
|
email="outsider@community.org",
|
|
password="SecurePassword123!",
|
|
fullname="Ali Outsider",
|
|
user_type=User.UserType.CLIENT,
|
|
country="France",
|
|
city="Paris"
|
|
)
|
|
|
|
# Institutions
|
|
self.owner_inst = Institution.objects.create(
|
|
name="Astan Quds International Relations",
|
|
type=Institution.InstitutionType.INSTITUTE,
|
|
country="Iran",
|
|
city="Mashhad"
|
|
)
|
|
InstitutionMember.objects.create(
|
|
institution=self.owner_inst,
|
|
user=self.lead_admin,
|
|
role=InstitutionMember.MemberRole.ADMIN
|
|
)
|
|
|
|
self.collab_inst = Institution.objects.create(
|
|
name="Imam Ali Islamic Center Berlin",
|
|
type=Institution.InstitutionType.CULTURAL_CENTER,
|
|
country="Germany",
|
|
city="Berlin"
|
|
)
|
|
InstitutionMember.objects.create(
|
|
institution=self.collab_inst,
|
|
user=self.collab_editor,
|
|
role=InstitutionMember.MemberRole.EDITOR
|
|
)
|
|
|
|
# Sample Project
|
|
self.project = Project.objects.create(
|
|
title="International Encyclopedia of Razavi Manuscript Heritage",
|
|
description="Joint digital restoration and multilingual publication of historic manuscripts.",
|
|
owner_institution=self.owner_inst,
|
|
category="Cultural Diplomacy",
|
|
status=Project.Status.ACTIVE,
|
|
progress_percentage=35,
|
|
budget="€120,000",
|
|
team_size=8,
|
|
created_by=self.lead_admin
|
|
)
|
|
self.project.collaborating_institutions.add(self.collab_inst)
|
|
|
|
def test_project_creation_and_auto_columns(self):
|
|
self.client.force_authenticate(user=self.lead_admin)
|
|
|
|
payload = {
|
|
"title": "Inter-Center Youth Leadership Summit 2026",
|
|
"description": "Annual multilateral youth exchange program.",
|
|
"owner_institution": self.owner_inst.id,
|
|
"collaborating_institutions": [self.collab_inst.id],
|
|
"category": "Educational Academy",
|
|
"status": "active",
|
|
"progress_percentage": 10,
|
|
"budget": "€45,000",
|
|
"team_size": 4
|
|
}
|
|
|
|
response = self.client.post("/api/v1/projects/", payload, format="json")
|
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
self.assertEqual(response.data["title"], payload["title"])
|
|
self.assertTrue(response.data["slug"])
|
|
self.assertEqual(len(response.data["collaborating_institutions"]), 1)
|
|
|
|
# Verify auto-generated 4 default Kanban columns
|
|
project_id = response.data["id"]
|
|
columns_count = KanbanColumn.objects.filter(project_id=project_id).count()
|
|
self.assertEqual(columns_count, 4)
|
|
|
|
def test_project_listing_and_filtering(self):
|
|
# 1. Base list
|
|
response = self.client.get("/api/v1/projects/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertGreaterEqual(len(response.data["results"]), 1)
|
|
|
|
# 2. Filter by owner institution
|
|
resp_inst = self.client.get(f"/api/v1/projects/?institution_id={self.owner_inst.id}")
|
|
self.assertEqual(len(resp_inst.data["results"]), 1)
|
|
|
|
# 3. Filter by collaborating institution
|
|
resp_collab = self.client.get(f"/api/v1/projects/?institution_id={self.collab_inst.id}")
|
|
self.assertEqual(len(resp_collab.data["results"]), 1)
|
|
|
|
# 4. Filter by category
|
|
resp_cat = self.client.get("/api/v1/projects/?category=Cultural Diplomacy")
|
|
self.assertEqual(len(resp_cat.data["results"]), 1)
|
|
|
|
# 5. Search
|
|
resp_search = self.client.get("/api/v1/projects/?search=Manuscript")
|
|
self.assertEqual(len(resp_search.data["results"]), 1)
|
|
|
|
def test_project_detail_and_collaboration_links(self):
|
|
self.client.force_authenticate(user=self.lead_admin)
|
|
|
|
response = self.client.get(f"/api/v1/projects/{self.project.slug}/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["title"], self.project.title)
|
|
self.assertEqual(response.data["owner_institution"]["name"], self.owner_inst.name)
|
|
self.assertEqual(len(response.data["collaborating_institutions"]), 1)
|
|
self.assertEqual(response.data["collaborating_institutions"][0]["name"], self.collab_inst.name)
|
|
self.assertTrue(response.data["can_edit"])
|
|
|
|
def test_kanban_board_hierarchy(self):
|
|
response = self.client.get(f"/api/v1/projects/{self.project.id}/kanban/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(response.data), 4)
|
|
|
|
column_names = [col["name"] for col in response.data]
|
|
self.assertIn("To Do", column_names)
|
|
self.assertIn("In Progress", column_names)
|
|
self.assertIn("Review", column_names)
|
|
self.assertIn("Done", column_names)
|
|
|
|
def test_kanban_task_creation_and_reordering_across_columns(self):
|
|
self.client.force_authenticate(user=self.lead_admin)
|
|
|
|
todo_column = self.project.columns.get(code="todo")
|
|
in_progress_column = self.project.columns.get(code="in_progress")
|
|
done_column = self.project.columns.get(code="done")
|
|
|
|
# 1. Create task in To Do
|
|
task_payload = {
|
|
"column": todo_column.id,
|
|
"title": "Digitize 15th Century Manuscript Folios",
|
|
"description": "High-resolution 1200 DPI archival scans.",
|
|
"priority": "high",
|
|
"assignee": self.collab_editor.id,
|
|
"labels": ["Archival", "Digital Preservation"],
|
|
"order": 1
|
|
}
|
|
create_task_resp = self.client.post(f"/api/v1/projects/{self.project.id}/tasks/", task_payload, format="json")
|
|
self.assertEqual(create_task_resp.status_code, status.HTTP_201_CREATED)
|
|
task_id = create_task_resp.data["id"]
|
|
self.assertEqual(create_task_resp.data["column_code"], "todo")
|
|
|
|
# 2. Move task to In Progress (Drag-and-Drop Simulation)
|
|
move_resp = self.client.patch(
|
|
f"/api/v1/projects/{self.project.id}/tasks/{task_id}/",
|
|
{"column": in_progress_column.id, "order": 2},
|
|
format="json"
|
|
)
|
|
self.assertEqual(move_resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(move_resp.data["column_code"], "in_progress")
|
|
|
|
# 3. Complete task -> Move to Done
|
|
done_resp = self.client.patch(
|
|
f"/api/v1/projects/{self.project.id}/tasks/{task_id}/",
|
|
{"column": done_column.id},
|
|
format="json"
|
|
)
|
|
self.assertEqual(done_resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(done_resp.data["column_code"], "done")
|
|
|
|
# 4. Delete task
|
|
del_resp = self.client.delete(f"/api/v1/projects/{self.project.id}/tasks/{task_id}/")
|
|
self.assertEqual(del_resp.status_code, status.HTTP_204_NO_CONTENT)
|
|
self.assertEqual(KanbanTask.objects.filter(id=task_id).count(), 0)
|
|
|
|
def test_project_patch_update_and_permissions(self):
|
|
patch_payload = {
|
|
"progress_percentage": 50,
|
|
"status": "active"
|
|
}
|
|
|
|
# 1. Unauthenticated user gets 401
|
|
unauth_resp = self.client.patch(f"/api/v1/projects/{self.project.id}/", patch_payload, format="json")
|
|
self.assertEqual(unauth_resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
|
|
|
# 2. Unrelated user gets 403
|
|
self.client.force_authenticate(user=self.unrelated_user)
|
|
forbid_resp = self.client.patch(f"/api/v1/projects/{self.project.id}/", patch_payload, format="json")
|
|
self.assertEqual(forbid_resp.status_code, status.HTTP_403_FORBIDDEN)
|
|
|
|
# 3. Collaborating institution editor can update progress
|
|
self.client.force_authenticate(user=self.collab_editor)
|
|
collab_resp = self.client.patch(f"/api/v1/projects/{self.project.id}/", patch_payload, format="json")
|
|
self.assertEqual(collab_resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(collab_resp.data["progress_percentage"], 50)
|
|
|
|
def test_project_document_upload_and_delete(self):
|
|
self.client.force_authenticate(user=self.collab_editor)
|
|
|
|
mock_doc = SimpleUploadedFile("bilateral_mou_2026.pdf", b"%PDF-1.4 Mock Bilateral MOU", content_type="application/pdf")
|
|
upload_resp = self.client.post(
|
|
f"/api/v1/projects/{self.project.id}/documents/",
|
|
{
|
|
"title": "Bilateral Cooperation MOU Astan-Berlin",
|
|
"doc_type": "mou",
|
|
"file": mock_doc
|
|
},
|
|
format="multipart"
|
|
)
|
|
self.assertEqual(upload_resp.status_code, status.HTTP_201_CREATED)
|
|
doc_id = upload_resp.data["id"]
|
|
|
|
# List documents
|
|
list_resp = self.client.get(f"/api/v1/projects/{self.project.id}/documents/")
|
|
self.assertEqual(list_resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(list_resp.data), 1)
|
|
|
|
# Delete document
|
|
del_resp = self.client.delete(f"/api/v1/projects/{self.project.id}/documents/{doc_id}/")
|
|
self.assertEqual(del_resp.status_code, status.HTTP_204_NO_CONTENT)
|
|
self.assertEqual(ProjectDocument.objects.filter(id=doc_id).count(), 0)
|