FossilRepo

fossilrepo / tests / test_project_groups.py
Source Blame History 347 lines
c588255… ragelink 1 """Tests for Project Groups: model, views, sidebar context, and permissions."""
c588255… ragelink 2
c588255… ragelink 3 import pytest
c588255… ragelink 4 from django.contrib.auth.models import Group, Permission
c588255… ragelink 5 from django.test import Client
c588255… ragelink 6
c588255… ragelink 7 from projects.models import Project, ProjectGroup
c588255… ragelink 8
c588255… ragelink 9
c588255… ragelink 10 @pytest.fixture
c588255… ragelink 11 def sample_group(db, admin_user):
c588255… ragelink 12 return ProjectGroup.objects.create(name="Fossil SCM", description="The Fossil repos", created_by=admin_user)
c588255… ragelink 13
c588255… ragelink 14
c588255… ragelink 15 @pytest.fixture
c588255… ragelink 16 def editor_user(db):
c588255… ragelink 17 """User with full project and projectgroup permissions (add/change/delete/view) but not superuser."""
c588255… ragelink 18 user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user(
c588255… ragelink 19 username="editor", email="[email protected]", password="testpass123"
c588255… ragelink 20 )
c588255… ragelink 21 group, _ = Group.objects.get_or_create(name="Editors")
c588255… ragelink 22 perms = Permission.objects.filter(
c588255… ragelink 23 content_type__app_label="projects",
c588255… ragelink 24 )
c588255… ragelink 25 group.permissions.set(perms)
c588255… ragelink 26 user.groups.add(group)
c588255… ragelink 27 return user
c588255… ragelink 28
c588255… ragelink 29
c588255… ragelink 30 @pytest.fixture
c588255… ragelink 31 def editor_client(editor_user):
c588255… ragelink 32 client = Client()
c588255… ragelink 33 client.login(username="editor", password="testpass123")
c588255… ragelink 34 return client
c588255… ragelink 35
c588255… ragelink 36
c588255… ragelink 37 # --- Model Tests ---
c588255… ragelink 38
c588255… ragelink 39
c588255… ragelink 40 @pytest.mark.django_db
c588255… ragelink 41 class TestProjectGroupModel:
c588255… ragelink 42 def test_create_group(self, admin_user):
c588255… ragelink 43 group = ProjectGroup.objects.create(name="Test Group", created_by=admin_user)
c588255… ragelink 44 assert group.slug == "test-group"
c588255… ragelink 45 assert str(group) == "Test Group"
c588255… ragelink 46 assert group.guid is not None
c588255… ragelink 47
c588255… ragelink 48 def test_slug_auto_generated(self, admin_user):
c588255… ragelink 49 group = ProjectGroup.objects.create(name="My Cool Group", created_by=admin_user)
c588255… ragelink 50 assert group.slug == "my-cool-group"
c588255… ragelink 51
c588255… ragelink 52 def test_slug_uniqueness(self, admin_user):
c588255… ragelink 53 ProjectGroup.objects.create(name="Dupes", created_by=admin_user)
c588255… ragelink 54 g2 = ProjectGroup.objects.create(name="Dupes", created_by=admin_user)
c588255… ragelink 55 assert g2.slug == "dupes-1"
c588255… ragelink 56
c588255… ragelink 57 def test_soft_delete(self, admin_user):
c588255… ragelink 58 group = ProjectGroup.objects.create(name="Deletable", created_by=admin_user)
c588255… ragelink 59 group.soft_delete(user=admin_user)
c588255… ragelink 60 assert group.is_deleted
c588255… ragelink 61 assert ProjectGroup.objects.filter(name="Deletable").count() == 0
c588255… ragelink 62 assert ProjectGroup.all_objects.filter(name="Deletable").count() == 1
c588255… ragelink 63
c588255… ragelink 64 def test_project_group_fk(self, admin_user, org, sample_group):
c588255… ragelink 65 project = Project.objects.create(name="Grouped Project", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 66 assert project.group == sample_group
c588255… ragelink 67 assert project in sample_group.projects.all()
c588255… ragelink 68
c588255… ragelink 69 def test_project_group_nullable(self, admin_user, org):
c588255… ragelink 70 project = Project.objects.create(name="Ungrouped", organization=org, created_by=admin_user)
c588255… ragelink 71 assert project.group is None
c588255… ragelink 72
c588255… ragelink 73 def test_group_deletion_sets_null(self, admin_user, org, sample_group):
c588255… ragelink 74 project = Project.objects.create(name="Will Unlink", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 75 sample_group.delete()
c588255… ragelink 76 project.refresh_from_db()
c588255… ragelink 77 assert project.group is None
c588255… ragelink 78
c588255… ragelink 79
c588255… ragelink 80 # --- View Tests: Group List ---
c588255… ragelink 81
c588255… ragelink 82
c588255… ragelink 83 @pytest.mark.django_db
c588255… ragelink 84 class TestGroupListView:
c588255… ragelink 85 def test_list_allowed_for_superuser(self, admin_client, sample_group):
c588255… ragelink 86 response = admin_client.get("/projects/groups/")
c588255… ragelink 87 assert response.status_code == 200
c588255… ragelink 88 assert "Fossil SCM" in response.content.decode()
c588255… ragelink 89
c588255… ragelink 90 def test_list_allowed_for_viewer(self, viewer_client, sample_group):
c588255… ragelink 91 response = viewer_client.get("/projects/groups/")
c588255… ragelink 92 assert response.status_code == 200
c588255… ragelink 93
c588255… ragelink 94 def test_list_denied_for_no_perm(self, no_perm_client):
c588255… ragelink 95 response = no_perm_client.get("/projects/groups/")
c588255… ragelink 96 assert response.status_code == 403
c588255… ragelink 97
c588255… ragelink 98 def test_list_denied_for_anon(self, client):
c588255… ragelink 99 response = client.get("/projects/groups/")
c588255… ragelink 100 assert response.status_code == 302
c588255… ragelink 101
c588255… ragelink 102 def test_list_shows_project_count(self, admin_client, admin_user, org, sample_group):
c588255… ragelink 103 Project.objects.create(name="P1", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 104 Project.objects.create(name="P2", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 105 response = admin_client.get("/projects/groups/")
c588255… ragelink 106 content = response.content.decode()
c588255… ragelink 107 assert "Fossil SCM" in content
c588255… ragelink 108
c588255… ragelink 109 def test_list_htmx_returns_partial(self, admin_client, sample_group):
c588255… ragelink 110 response = admin_client.get("/projects/groups/", HTTP_HX_REQUEST="true")
c588255… ragelink 111 assert response.status_code == 200
c588255… ragelink 112 content = response.content.decode()
c588255… ragelink 113 assert "Fossil SCM" in content
c588255… ragelink 114 # Partial should not have full page structure
c588255… ragelink 115 assert "<!DOCTYPE html>" not in content
c588255… ragelink 116
c588255… ragelink 117
c588255… ragelink 118 # --- View Tests: Group Create ---
c588255… ragelink 119
c588255… ragelink 120
c588255… ragelink 121 @pytest.mark.django_db
c588255… ragelink 122 class TestGroupCreateView:
c588255… ragelink 123 def test_create_get_allowed_for_superuser(self, admin_client):
c588255… ragelink 124 response = admin_client.get("/projects/groups/create/")
c588255… ragelink 125 assert response.status_code == 200
c588255… ragelink 126
c588255… ragelink 127 def test_create_get_allowed_for_editor(self, editor_client):
c588255… ragelink 128 response = editor_client.get("/projects/groups/create/")
c588255… ragelink 129 assert response.status_code == 200
c588255… ragelink 130
c588255… ragelink 131 def test_create_denied_for_viewer(self, viewer_client):
c588255… ragelink 132 response = viewer_client.get("/projects/groups/create/")
c588255… ragelink 133 assert response.status_code == 403
c588255… ragelink 134
c588255… ragelink 135 def test_create_denied_for_no_perm(self, no_perm_client):
c588255… ragelink 136 response = no_perm_client.get("/projects/groups/create/")
c588255… ragelink 137 assert response.status_code == 403
c588255… ragelink 138
c588255… ragelink 139 def test_create_denied_for_anon(self, client):
c588255… ragelink 140 response = client.get("/projects/groups/create/")
c588255… ragelink 141 assert response.status_code == 302
c588255… ragelink 142
c588255… ragelink 143 def test_create_saves_group(self, admin_client, admin_user):
c588255… ragelink 144 response = admin_client.post("/projects/groups/create/", {"name": "New Group", "description": "Desc"})
c588255… ragelink 145 assert response.status_code == 302
c588255… ragelink 146 group = ProjectGroup.objects.get(name="New Group")
c588255… ragelink 147 assert group.description == "Desc"
c588255… ragelink 148 assert group.created_by == admin_user
c588255… ragelink 149
c588255… ragelink 150 def test_create_redirects_to_detail(self, admin_client):
c588255… ragelink 151 response = admin_client.post("/projects/groups/create/", {"name": "Redirect Test"})
c588255… ragelink 152 assert response.status_code == 302
c588255… ragelink 153 group = ProjectGroup.objects.get(name="Redirect Test")
c588255… ragelink 154 assert response.url == f"/projects/groups/{group.slug}/"
c588255… ragelink 155
c588255… ragelink 156 def test_create_requires_name(self, admin_client):
c588255… ragelink 157 response = admin_client.post("/projects/groups/create/", {"name": "", "description": "No name"})
c588255… ragelink 158 assert response.status_code == 200 # Re-renders form
c588255… ragelink 159
c588255… ragelink 160
c588255… ragelink 161 # --- View Tests: Group Detail ---
c588255… ragelink 162
c588255… ragelink 163
c588255… ragelink 164 @pytest.mark.django_db
c588255… ragelink 165 class TestGroupDetailView:
c588255… ragelink 166 def test_detail_allowed_for_superuser(self, admin_client, sample_group):
c588255… ragelink 167 response = admin_client.get(f"/projects/groups/{sample_group.slug}/")
c588255… ragelink 168 assert response.status_code == 200
c588255… ragelink 169 assert "Fossil SCM" in response.content.decode()
c588255… ragelink 170
c588255… ragelink 171 def test_detail_allowed_for_viewer(self, viewer_client, sample_group):
c588255… ragelink 172 response = viewer_client.get(f"/projects/groups/{sample_group.slug}/")
c588255… ragelink 173 assert response.status_code == 200
c588255… ragelink 174
c588255… ragelink 175 def test_detail_denied_for_no_perm(self, no_perm_client, sample_group):
c588255… ragelink 176 response = no_perm_client.get(f"/projects/groups/{sample_group.slug}/")
c588255… ragelink 177 assert response.status_code == 403
c588255… ragelink 178
c588255… ragelink 179 def test_detail_denied_for_anon(self, client, sample_group):
c588255… ragelink 180 response = client.get(f"/projects/groups/{sample_group.slug}/")
c588255… ragelink 181 assert response.status_code == 302
c588255… ragelink 182
c588255… ragelink 183 def test_detail_shows_member_projects(self, admin_client, admin_user, org, sample_group):
c588255… ragelink 184 Project.objects.create(name="Fossil Source", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 185 response = admin_client.get(f"/projects/groups/{sample_group.slug}/")
c588255… ragelink 186 assert "Fossil Source" in response.content.decode()
c588255… ragelink 187
c588255… ragelink 188 def test_detail_404_for_deleted_group(self, admin_client, admin_user, sample_group):
c588255… ragelink 189 sample_group.soft_delete(user=admin_user)
c588255… ragelink 190 response = admin_client.get(f"/projects/groups/{sample_group.slug}/")
c588255… ragelink 191 assert response.status_code == 404
c588255… ragelink 192
c588255… ragelink 193
c588255… ragelink 194 # --- View Tests: Group Edit ---
c588255… ragelink 195
c588255… ragelink 196
c588255… ragelink 197 @pytest.mark.django_db
c588255… ragelink 198 class TestGroupEditView:
c588255… ragelink 199 def test_edit_get_allowed_for_superuser(self, admin_client, sample_group):
c588255… ragelink 200 response = admin_client.get(f"/projects/groups/{sample_group.slug}/edit/")
c588255… ragelink 201 assert response.status_code == 200
c588255… ragelink 202
c588255… ragelink 203 def test_edit_get_allowed_for_editor(self, editor_client, sample_group):
c588255… ragelink 204 response = editor_client.get(f"/projects/groups/{sample_group.slug}/edit/")
c588255… ragelink 205 assert response.status_code == 200
c588255… ragelink 206
c588255… ragelink 207 def test_edit_denied_for_viewer(self, viewer_client, sample_group):
c588255… ragelink 208 response = viewer_client.get(f"/projects/groups/{sample_group.slug}/edit/")
c588255… ragelink 209 assert response.status_code == 403
c588255… ragelink 210
c588255… ragelink 211 def test_edit_denied_for_no_perm(self, no_perm_client, sample_group):
c588255… ragelink 212 response = no_perm_client.get(f"/projects/groups/{sample_group.slug}/edit/")
c588255… ragelink 213 assert response.status_code == 403
c588255… ragelink 214
c588255… ragelink 215 def test_edit_saves_changes(self, admin_client, admin_user, sample_group):
c588255… ragelink 216 response = admin_client.post(
c588255… ragelink 217 f"/projects/groups/{sample_group.slug}/edit/",
c588255… ragelink 218 {"name": "Fossil SCM Updated", "description": "Updated desc"},
c588255… ragelink 219 )
c588255… ragelink 220 assert response.status_code == 302
c588255… ragelink 221 sample_group.refresh_from_db()
c588255… ragelink 222 assert sample_group.name == "Fossil SCM Updated"
c588255… ragelink 223 assert sample_group.description == "Updated desc"
c588255… ragelink 224 assert sample_group.updated_by == admin_user
c588255… ragelink 225
c588255… ragelink 226
c588255… ragelink 227 # --- View Tests: Group Delete ---
c588255… ragelink 228
c588255… ragelink 229
c588255… ragelink 230 @pytest.mark.django_db
c588255… ragelink 231 class TestGroupDeleteView:
c588255… ragelink 232 def test_delete_get_shows_confirmation(self, admin_client, sample_group):
c588255… ragelink 233 response = admin_client.get(f"/projects/groups/{sample_group.slug}/delete/")
c588255… ragelink 234 assert response.status_code == 200
c588255… ragelink 235 assert "Fossil SCM" in response.content.decode()
c588255… ragelink 236 assert "Delete" in response.content.decode()
c588255… ragelink 237
c588255… ragelink 238 def test_delete_denied_for_viewer(self, viewer_client, sample_group):
c588255… ragelink 239 response = viewer_client.post(f"/projects/groups/{sample_group.slug}/delete/")
c588255… ragelink 240 assert response.status_code == 403
c588255… ragelink 241
c588255… ragelink 242 def test_delete_denied_for_no_perm(self, no_perm_client, sample_group):
c588255… ragelink 243 response = no_perm_client.post(f"/projects/groups/{sample_group.slug}/delete/")
c588255… ragelink 244 assert response.status_code == 403
c588255… ragelink 245
c588255… ragelink 246 def test_delete_soft_deletes_group(self, admin_client, admin_user, sample_group):
c588255… ragelink 247 response = admin_client.post(f"/projects/groups/{sample_group.slug}/delete/")
c588255… ragelink 248 assert response.status_code == 302
c588255… ragelink 249 sample_group.refresh_from_db()
c588255… ragelink 250 assert sample_group.is_deleted
c588255… ragelink 251
c588255… ragelink 252 def test_delete_unlinks_projects(self, admin_client, admin_user, org, sample_group):
c588255… ragelink 253 project = Project.objects.create(name="Linked", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 254 admin_client.post(f"/projects/groups/{sample_group.slug}/delete/")
c588255… ragelink 255 project.refresh_from_db()
c588255… ragelink 256 assert project.group is None
c588255… ragelink 257 assert not project.is_deleted # Project survives
c588255… ragelink 258
c588255… ragelink 259 def test_delete_htmx_redirect(self, admin_client, sample_group):
c588255… ragelink 260 response = admin_client.post(f"/projects/groups/{sample_group.slug}/delete/", HTTP_HX_REQUEST="true")
c588255… ragelink 261 assert response.status_code == 200
c588255… ragelink 262 assert response["HX-Redirect"] == "/projects/groups/"
c588255… ragelink 263
c588255… ragelink 264
c588255… ragelink 265 # --- Form Tests ---
c588255… ragelink 266
c588255… ragelink 267
c588255… ragelink 268 @pytest.mark.django_db
c588255… ragelink 269 class TestProjectGroupForm:
c588255… ragelink 270 def test_form_valid(self):
c588255… ragelink 271 from projects.forms import ProjectGroupForm
c588255… ragelink 272
c588255… ragelink 273 form = ProjectGroupForm(data={"name": "Test Group", "description": "A group"})
c588255… ragelink 274 assert form.is_valid()
c588255… ragelink 275
c588255… ragelink 276 def test_form_valid_without_description(self):
c588255… ragelink 277 from projects.forms import ProjectGroupForm
c588255… ragelink 278
c588255… ragelink 279 form = ProjectGroupForm(data={"name": "Test Group", "description": ""})
c588255… ragelink 280 assert form.is_valid()
c588255… ragelink 281
c588255… ragelink 282 def test_form_invalid_without_name(self):
c588255… ragelink 283 from projects.forms import ProjectGroupForm
c588255… ragelink 284
c588255… ragelink 285 form = ProjectGroupForm(data={"name": "", "description": "No name"})
c588255… ragelink 286 assert not form.is_valid()
c588255… ragelink 287 assert "name" in form.errors
c588255… ragelink 288
c588255… ragelink 289
c588255… ragelink 290 # --- ProjectForm group field ---
c588255… ragelink 291
c588255… ragelink 292
c588255… ragelink 293 @pytest.mark.django_db
c588255… ragelink 294 class TestProjectFormGroupField:
c588255… ragelink 295 def test_project_form_includes_group(self, sample_group):
c588255… ragelink 296 from projects.forms import ProjectForm
c588255… ragelink 297
c588255… ragelink 298 form = ProjectForm(data={"name": "Test", "visibility": "private", "group": sample_group.pk})
c588255… ragelink 299 assert form.is_valid()
c588255… ragelink 300 project_data = form.cleaned_data
c588255… ragelink 301 assert project_data["group"] == sample_group
c588255… ragelink 302
c588255… ragelink 303 def test_project_form_group_optional(self):
c588255… ragelink 304 from projects.forms import ProjectForm
c588255… ragelink 305
c588255… ragelink 306 form = ProjectForm(data={"name": "No Group", "visibility": "private"})
c588255… ragelink 307 assert form.is_valid()
c588255… ragelink 308 assert form.cleaned_data["group"] is None
c588255… ragelink 309
c588255… ragelink 310 def test_project_create_with_group(self, admin_client, org, sample_group):
c588255… ragelink 311 response = admin_client.post(
c588255… ragelink 312 "/projects/create/",
c588255… ragelink 313 {"name": "Grouped Via Form", "visibility": "private", "group": sample_group.pk},
c588255… ragelink 314 )
c588255… ragelink 315 assert response.status_code == 302
c588255… ragelink 316 project = Project.objects.get(name="Grouped Via Form")
c588255… ragelink 317 assert project.group == sample_group
c588255… ragelink 318
c588255… ragelink 319
c588255… ragelink 320 # --- Context Processor Tests ---
c588255… ragelink 321
c588255… ragelink 322
c588255… ragelink 323 @pytest.mark.django_db
c588255… ragelink 324 class TestSidebarContext:
c588255… ragelink 325 def test_grouped_projects_in_context(self, admin_client, admin_user, org, sample_group):
c588255… ragelink 326 Project.objects.create(name="Grouped P", organization=org, group=sample_group, created_by=admin_user)
c588255… ragelink 327 Project.objects.create(name="Ungrouped P", organization=org, created_by=admin_user)
c588255… ragelink 328 response = admin_client.get("/dashboard/")
c588255… ragelink 329 assert response.status_code == 200
c588255… ragelink 330 context = response.context
c588255… ragelink 331 assert "sidebar_grouped" in context
c588255… ragelink 332 assert "sidebar_ungrouped" in context
c588255… ragelink 333 assert len(context["sidebar_grouped"]) == 1
c588255… ragelink 334 assert context["sidebar_grouped"][0]["group"].name == "Fossil SCM"
c588255… ragelink 335 ungrouped_names = [p.name for p in context["sidebar_ungrouped"]]
c588255… ragelink 336 assert "Ungrouped P" in ungrouped_names
c588255… ragelink 337
c588255… ragelink 338 def test_empty_group_not_in_sidebar(self, admin_client, sample_group):
c588255… ragelink 339 """A group with no projects should not appear in sidebar_grouped."""
c588255… ragelink 340 response = admin_client.get("/dashboard/")
c588255… ragelink 341 context = response.context
c588255… ragelink 342 assert len(context["sidebar_grouped"]) == 0
c588255… ragelink 343
c588255… ragelink 344 def test_unauthenticated_gets_empty_context(self, client):
c588255… ragelink 345 response = client.get("/dashboard/")
c588255… ragelink 346 # Redirects to login, but if we could check context it would be empty
c588255… ragelink 347 assert response.status_code == 302

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button