2026-02-27 12:12:26 +00:00
|
|
|
from django.test import TestCase
|
2026-03-08 12:55:28 +00:00
|
|
|
from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
|
2026-02-27 12:12:26 +00:00
|
|
|
from rest_framework.test import APIClient
|
|
|
|
|
|
2026-02-27 15:21:46 +00:00
|
|
|
from apps.accounts.models import Invite, Organization, Role, User
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
class AccountsApiTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.client: APIClient = APIClient()
|
2026-02-27 15:21:46 +00:00
|
|
|
self.manager: User = User.objects.create_user(
|
2026-02-27 12:12:26 +00:00
|
|
|
email_address='manager@example.com',
|
|
|
|
|
password='pass1234',
|
|
|
|
|
first_name='Manager',
|
|
|
|
|
last_name='User',
|
|
|
|
|
date_of_birth='1990-01-01',
|
|
|
|
|
is_manager=True,
|
|
|
|
|
)
|
2026-02-27 15:21:46 +00:00
|
|
|
self.member: User = User.objects.create_user(
|
2026-02-27 12:12:26 +00:00
|
|
|
email_address='member@example.com',
|
|
|
|
|
password='pass1234',
|
|
|
|
|
first_name='Member',
|
|
|
|
|
last_name='User',
|
|
|
|
|
date_of_birth='1992-02-02',
|
|
|
|
|
)
|
2026-02-27 15:21:46 +00:00
|
|
|
self.other: User = User.objects.create_user(
|
2026-02-27 12:12:26 +00:00
|
|
|
email_address='other@example.com',
|
|
|
|
|
password='pass1234',
|
|
|
|
|
first_name='Other',
|
|
|
|
|
last_name='User',
|
|
|
|
|
date_of_birth='1993-03-03',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.organization = Organization.objects.create(
|
|
|
|
|
name='Team Alpha',
|
|
|
|
|
description='Main team',
|
|
|
|
|
owner=self.manager,
|
|
|
|
|
)
|
|
|
|
|
self.organization.members.add(self.manager, self.member)
|
|
|
|
|
self.role = Role.objects.create(name='Developer', organization=self.organization)
|
|
|
|
|
|
|
|
|
|
def test_user_list_path(self):
|
|
|
|
|
response = self.client.get('/api/user/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_user_retrieve_path(self):
|
|
|
|
|
response = self.client.get(f'/api/user/{self.manager.uuid}/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_user_login_path(self):
|
|
|
|
|
response = self.client.post('/api/user/login/', {
|
|
|
|
|
'email_address': 'manager@example.com',
|
|
|
|
|
'password': 'pass1234',
|
|
|
|
|
})
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
self.assertTrue(response.json().get('success'))
|
|
|
|
|
|
|
|
|
|
def test_user_logout_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post('/api/user/logout/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_user_me_path(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
|
|
|
|
response = self.client.get('/api/user/me/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
self.assertEqual(response.json()['email_address'], 'member@example.com')
|
|
|
|
|
|
|
|
|
|
def test_user_session_path(self):
|
|
|
|
|
response = self.client.get('/api/user/session/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
self.assertIn('isAuthenticated', response.json())
|
|
|
|
|
|
|
|
|
|
def test_user_signup_path(self):
|
|
|
|
|
response = self.client.post('/api/user/signup/', {
|
|
|
|
|
'email_address': 'signup@example.com',
|
|
|
|
|
'password': 'newpass123',
|
|
|
|
|
'confirm_password': 'newpass123',
|
|
|
|
|
'first_name': 'Sign',
|
|
|
|
|
'last_name': 'Up',
|
|
|
|
|
'date_of_birth': '1995-05-05',
|
|
|
|
|
'manager': False,
|
|
|
|
|
}, format='json')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_201_CREATED)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_user_change_password_path(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
|
|
|
|
response = self.client.post('/api/user/change_password/', {
|
|
|
|
|
'old_password': 'pass1234',
|
|
|
|
|
'password': 'newpass123',
|
|
|
|
|
'confirm_password': 'newpass123',
|
|
|
|
|
}, format='json')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_list_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.get('/api/organization/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_create_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post('/api/organization/', {
|
|
|
|
|
'name': 'Team Beta',
|
|
|
|
|
'description': 'Second team',
|
|
|
|
|
}, format='json')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_201_CREATED)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_retrieve_path(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
|
|
|
|
response = self.client.get(f'/api/organization/{self.organization.uuid}/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_update_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.put(
|
|
|
|
|
f'/api/organization/{self.organization.uuid}/',
|
|
|
|
|
{'name': 'Team Alpha Updated', 'description': 'Updated'},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_partial_update_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.patch(
|
|
|
|
|
f'/api/organization/{self.organization.uuid}/',
|
|
|
|
|
{'description': 'Patched'},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_delete_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
org = Organization.objects.create(name='Delete Me', owner=self.manager)
|
|
|
|
|
org.members.add(self.manager)
|
|
|
|
|
response = self.client.delete(f'/api/organization/{org.uuid}/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_invite_list_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
Invite.objects.create(organization=self.organization, created_by=self.manager)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.get(f'/api/invite/?organization_uuid={self.organization.uuid}')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_create_invite_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.post(f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=2', {}, format='json')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_201_CREATED)
|
2026-02-27 15:21:46 +00:00
|
|
|
self.assertIn('uuid', response.json())
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_revoke_invite_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
invite = Invite.objects.create(organization=self.organization, created_by=self.manager)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.delete(f'/api/invite/{invite.uuid}/?organization_uuid={self.organization.uuid}')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_join_path(self):
|
|
|
|
|
self.client.force_authenticate(self.other)
|
|
|
|
|
invite = Invite.objects.create(organization=self.organization, created_by=self.manager)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.post(f'/api/invite/join/?invite_uuid={invite.uuid}')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_leave_path(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
2026-03-08 12:55:28 +00:00
|
|
|
self.role.members.add(self.member)
|
2026-02-27 12:12:26 +00:00
|
|
|
response = self.client.post(f'/api/organization/{self.organization.uuid}/leave/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
|
|
|
|
self.organization.refresh_from_db()
|
|
|
|
|
self.assertFalse(self.organization.members.filter(uuid=self.member.uuid).exists())
|
|
|
|
|
self.assertFalse(self.role.members.filter(uuid=self.member.uuid).exists())
|
|
|
|
|
|
|
|
|
|
def test_organization_leave_owner_blocked_when_other_members_exist(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(f'/api/organization/{self.organization.uuid}/leave/')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
|
|
|
|
|
self.assertIn('Owner cannot leave while other members/managers exist', response.json().get('error', ''))
|
|
|
|
|
self.assertTrue(Organization.objects.filter(uuid=self.organization.uuid).exists())
|
|
|
|
|
|
|
|
|
|
def test_organization_leave_owner_deletes_org_when_no_other_members(self):
|
|
|
|
|
solo_org = Organization.objects.create(
|
|
|
|
|
name='Solo Owner Org',
|
|
|
|
|
description='Only owner remains',
|
|
|
|
|
owner=self.manager,
|
|
|
|
|
)
|
|
|
|
|
solo_org.members.add(self.manager)
|
|
|
|
|
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(f'/api/organization/{solo_org.uuid}/leave/')
|
|
|
|
|
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
|
|
|
|
self.assertFalse(Organization.objects.filter(uuid=solo_org.uuid).exists())
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_members_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.get(f'/api/organization/{self.organization.uuid}/members/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_remove_member_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
2026-02-27 15:21:46 +00:00
|
|
|
response = self.client.post(f'/api/organization/{self.organization.uuid}/member/{self.member.uuid}/remove/')
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_roles_get_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.get(f'/api/role/?organization_uuid={self.organization.uuid}')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_roles_post_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(
|
2026-03-08 12:55:28 +00:00
|
|
|
f'/api/role/?organization_uuid={self.organization.uuid}',
|
2026-02-27 12:12:26 +00:00
|
|
|
{'name': 'Designer', 'description': 'Design role'},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
2026-03-08 12:55:28 +00:00
|
|
|
self.assertEqual(response.status_code, HTTP_201_CREATED)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_my_roles_path(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
|
|
|
|
self.role.members.add(self.member)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.get('/api/role/mine/')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_delete_role_path(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
delete_role = Role.objects.create(name='DeleteRole', organization=self.organization)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.delete(f'/api/role/{delete_role.uuid}/?organization_uuid={self.organization.uuid}')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
|
2026-02-27 12:12:26 +00:00
|
|
|
|
|
|
|
|
def test_organization_join_role_path(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
2026-03-08 12:55:28 +00:00
|
|
|
response = self.client.post(f'/api/role/{self.role.uuid}/join/?organization_uuid={self.organization.uuid}')
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_200_OK)
|
|
|
|
|
|
|
|
|
|
def test_invite_create_rejects_non_integer_max_uses(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=abc',
|
|
|
|
|
{},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
|
|
|
|
|
self.assertIn('max_uses', response.json())
|
|
|
|
|
|
|
|
|
|
def test_invite_create_rejects_out_of_bounds_max_uses(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
|
|
|
|
|
low = self.client.post(
|
|
|
|
|
f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=0',
|
|
|
|
|
{},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
high = self.client.post(
|
|
|
|
|
f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=1001',
|
|
|
|
|
{},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(low.status_code, HTTP_400_BAD_REQUEST)
|
|
|
|
|
self.assertEqual(high.status_code, HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
|
|
|
|
def test_invite_create_accepts_max_uses_boundaries(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
|
|
|
|
|
low = self.client.post(
|
|
|
|
|
f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=1',
|
|
|
|
|
{},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
high = self.client.post(
|
|
|
|
|
f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=1000',
|
|
|
|
|
{},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(low.status_code, HTTP_201_CREATED)
|
|
|
|
|
self.assertEqual(high.status_code, HTTP_201_CREATED)
|
|
|
|
|
|
|
|
|
|
def test_invite_join_fails_after_max_uses_reached(self):
|
|
|
|
|
other_2 = User.objects.create_user(
|
|
|
|
|
email_address='other2@example.com',
|
|
|
|
|
password='pass1234',
|
|
|
|
|
first_name='Other',
|
|
|
|
|
last_name='Two',
|
|
|
|
|
date_of_birth='1994-04-04',
|
|
|
|
|
)
|
|
|
|
|
invite = Invite.objects.create(
|
|
|
|
|
organization=self.organization,
|
|
|
|
|
created_by=self.manager,
|
|
|
|
|
max_uses=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.client.force_authenticate(self.other)
|
|
|
|
|
first = self.client.post(f'/api/invite/join/?invite_uuid={invite.uuid}')
|
|
|
|
|
self.assertEqual(first.status_code, HTTP_200_OK)
|
|
|
|
|
|
|
|
|
|
self.client.force_authenticate(other_2)
|
|
|
|
|
second = self.client.post(f'/api/invite/join/?invite_uuid={invite.uuid}')
|
|
|
|
|
self.assertEqual(second.status_code, HTTP_400_BAD_REQUEST)
|
|
|
|
|
self.assertIn('Invalid or expired invitation', second.json().get('error', ''))
|
|
|
|
|
|
|
|
|
|
def test_non_manager_member_cannot_create_invite(self):
|
|
|
|
|
self.client.force_authenticate(self.member)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
f'/api/invite/?organization_uuid={self.organization.uuid}&max_uses=2',
|
|
|
|
|
{},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
|
|
|
|
|
|
|
|
|
|
def test_owner_cannot_be_removed_as_member(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
f'/api/organization/{self.organization.uuid}/member/{self.manager.uuid}/remove/'
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
|
|
|
|
|
|
|
|
|
|
def test_remove_member_not_found(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
f'/api/organization/{self.organization.uuid}/member/{self.other.uuid}/remove/'
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_404_NOT_FOUND)
|
|
|
|
|
|
|
|
|
|
def test_role_name_can_repeat_across_organizations(self):
|
|
|
|
|
second_org = Organization.objects.create(
|
|
|
|
|
name='Team Beta Scoped Role',
|
|
|
|
|
description='Second org',
|
|
|
|
|
owner=self.manager,
|
|
|
|
|
)
|
|
|
|
|
second_org.members.add(self.manager)
|
|
|
|
|
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
f'/api/role/?organization_uuid={second_org.uuid}',
|
|
|
|
|
{'name': 'Developer', 'description': 'Same role name in different org'},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_201_CREATED)
|
|
|
|
|
|
|
|
|
|
def test_role_name_must_be_unique_within_organization(self):
|
|
|
|
|
self.client.force_authenticate(self.manager)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
f'/api/role/?organization_uuid={self.organization.uuid}',
|
|
|
|
|
{'name': 'Developer', 'description': 'Duplicate role in same org'},
|
|
|
|
|
format='json',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
|
|
|
|
|
self.assertIn('name', response.json())
|