diff --git a/apps/accounts/tests/test_api.py b/apps/accounts/tests/test_api.py index f66a3a6..b4c347c 100644 --- a/apps/accounts/tests/test_api.py +++ b/apps/accounts/tests/test_api.py @@ -1,5 +1,5 @@ from django.test import TestCase -from rest_framework import status +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 from rest_framework.test import APIClient from apps.accounts.models import Invite, Organization, Role, User @@ -40,34 +40,34 @@ class AccountsApiTests(TestCase): def test_user_list_path(self): response = self.client.get('/api/user/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_user_retrieve_path(self): response = self.client.get(f'/api/user/{self.manager.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_user_login_path(self): response = self.client.post('/api/user/login/', { 'email_address': 'manager@example.com', 'password': 'pass1234', }) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) 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/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_user_me_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/user/me/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()['email_address'], 'member@example.com') def test_user_session_path(self): response = self.client.get('/api/user/session/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) self.assertIn('isAuthenticated', response.json()) def test_user_signup_path(self): @@ -80,7 +80,7 @@ class AccountsApiTests(TestCase): 'date_of_birth': '1995-05-05', 'manager': False, }, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.status_code, HTTP_201_CREATED) def test_user_change_password_path(self): self.client.force_authenticate(self.member) @@ -89,12 +89,12 @@ class AccountsApiTests(TestCase): 'password': 'newpass123', 'confirm_password': 'newpass123', }, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_list_path(self): self.client.force_authenticate(self.manager) response = self.client.get('/api/organization/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_create_path(self): self.client.force_authenticate(self.manager) @@ -102,12 +102,12 @@ class AccountsApiTests(TestCase): 'name': 'Team Beta', 'description': 'Second team', }, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.status_code, HTTP_201_CREATED) def test_organization_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/organization/{self.organization.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_update_path(self): self.client.force_authenticate(self.manager) @@ -116,7 +116,7 @@ class AccountsApiTests(TestCase): {'name': 'Team Alpha Updated', 'description': 'Updated'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_partial_update_path(self): self.client.force_authenticate(self.manager) @@ -125,81 +125,222 @@ class AccountsApiTests(TestCase): {'description': 'Patched'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) 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}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_organization_invite_list_path(self): self.client.force_authenticate(self.manager) Invite.objects.create(organization=self.organization, created_by=self.manager) - response = self.client.get(f'/api/organization/{self.organization.uuid}/invite/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.get(f'/api/invite/?organization_uuid={self.organization.uuid}') + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_create_invite_path(self): self.client.force_authenticate(self.manager) - response = self.client.post(f'/api/organization/{self.organization.uuid}/create-invite/?max_uses=2', {}, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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) self.assertIn('uuid', response.json()) def test_organization_revoke_invite_path(self): self.client.force_authenticate(self.manager) invite = Invite.objects.create(organization=self.organization, created_by=self.manager) - response = self.client.delete(f'/api/organization/{self.organization.uuid}/revoke-invite/{invite.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.delete(f'/api/invite/{invite.uuid}/?organization_uuid={self.organization.uuid}') + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_join_path(self): self.client.force_authenticate(self.other) invite = Invite.objects.create(organization=self.organization, created_by=self.manager) - response = self.client.post(f'/api/organization/join/{invite.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.post(f'/api/invite/join/?invite_uuid={invite.uuid}') + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_leave_path(self): self.client.force_authenticate(self.member) + self.role.members.add(self.member) response = self.client.post(f'/api/organization/{self.organization.uuid}/leave/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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()) def test_organization_members_path(self): self.client.force_authenticate(self.manager) response = self.client.get(f'/api/organization/{self.organization.uuid}/members/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_remove_member_path(self): self.client.force_authenticate(self.manager) response = self.client.post(f'/api/organization/{self.organization.uuid}/member/{self.member.uuid}/remove/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_roles_get_path(self): self.client.force_authenticate(self.manager) - response = self.client.get(f'/api/organization/{self.organization.uuid}/role/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.get(f'/api/role/?organization_uuid={self.organization.uuid}') + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_roles_post_path(self): self.client.force_authenticate(self.manager) response = self.client.post( - f'/api/organization/{self.organization.uuid}/role/', + f'/api/role/?organization_uuid={self.organization.uuid}', {'name': 'Designer', 'description': 'Design role'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.status_code, HTTP_201_CREATED) def test_organization_my_roles_path(self): self.client.force_authenticate(self.member) self.role.members.add(self.member) - response = self.client.get('/api/organization/role/mine/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.get('/api/role/mine/') + self.assertEqual(response.status_code, HTTP_200_OK) def test_organization_delete_role_path(self): self.client.force_authenticate(self.manager) delete_role = Role.objects.create(name='DeleteRole', organization=self.organization) - response = self.client.delete(f'/api/organization/{self.organization.uuid}/role/{delete_role.uuid}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response = self.client.delete(f'/api/role/{delete_role.uuid}/?organization_uuid={self.organization.uuid}') + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_organization_join_role_path(self): self.client.force_authenticate(self.member) - response = self.client.post(f'/api/organization/{self.organization.uuid}/role/{self.role.uuid}/join/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + 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()) diff --git a/apps/accounts/tests/test_models.py b/apps/accounts/tests/test_models.py index f28dc1f..e25ad37 100644 --- a/apps/accounts/tests/test_models.py +++ b/apps/accounts/tests/test_models.py @@ -1,4 +1,6 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.test import TestCase from django.utils import timezone @@ -95,3 +97,32 @@ class AccountsModelTests(TestCase): self.assertIsNotNone(role.updated_at) self.assertEqual(str(role), 'Engineer (Org C)') + + def test_owner_is_added_to_members_on_create(self): + org = Organization.objects.create(name='Org Owner Membership', owner=self.owner) + self.assertTrue(org.members.filter(id=self.owner.id).exists()) + + def test_owner_cannot_be_removed_from_members(self): + org = Organization.objects.create(name='Org Owner Locked', owner=self.owner) + org.members.add(self.owner) + + with self.assertRaises(ValidationError): + org.members.remove(self.owner) + + def test_owner_remains_member_after_clear(self): + org = Organization.objects.create(name='Org Clear Members', owner=self.owner) + org.members.add(self.owner, self.member) + org.members.clear() + + self.assertTrue(org.members.filter(id=self.owner.id).exists()) + self.assertFalse(org.members.filter(id=self.member.id).exists()) + + def test_role_name_unique_per_organization(self): + org = Organization.objects.create(name='Org Role Scoped', owner=self.owner) + other_org = Organization.objects.create(name='Org Role Scoped 2', owner=self.member) + Role.objects.create(name='Analyst', organization=org) + Role.objects.create(name='Analyst', organization=other_org) + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Role.objects.create(name='Analyst', organization=org) diff --git a/apps/knowledge/tests/test_api.py b/apps/knowledge/tests/test_api.py index 0a7cf7c..9ae5c2f 100644 --- a/apps/knowledge/tests/test_api.py +++ b/apps/knowledge/tests/test_api.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models.signals import post_save from django.test import TestCase -from rest_framework import status +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.test import APIClient from apps.accounts.models import Organization, Role @@ -63,22 +63,22 @@ class KnowledgeApiTests(TestCase): def test_training_file_list_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/training-file/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_training_file_create_path(self): self.client.force_authenticate(self.member) uploaded = SimpleUploadedFile('new.txt', b'new body', content_type='text/plain') response = self.client.post('/api/training-file/', { - 'role': str(self.role.uuid), + 'role_uuid': str(self.role.uuid), 'description': 'new file', 'file': uploaded, }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_training_file_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/training-file/{self.training_file.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_training_file_update_path(self): self.client.force_authenticate(self.owner) @@ -89,7 +89,7 @@ class KnowledgeApiTests(TestCase): 'file': SimpleUploadedFile('replace.txt', b'updated', content_type='text/plain'), }, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_training_file_partial_update_path(self): self.client.force_authenticate(self.owner) @@ -98,19 +98,72 @@ class KnowledgeApiTests(TestCase): {'description': 'patched desc'}, format='multipart', ) - self.assertIn(response.status_code, (status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST)) + self.assertIn(response.status_code, (HTTP_200_OK, HTTP_400_BAD_REQUEST)) def test_training_file_destroy_path(self): self.client.force_authenticate(self.owner) response = self.client.delete(f'/api/training-file/{self.training_file.uuid}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_role_rag_document_list_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/role-rag-document/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_role_rag_document_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/role-rag-document/{self.rag_doc.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_training_file_list_for_non_member_returns_empty(self): + outsider = User.objects.create_user( + email_address='outsider-k@example.com', + password='pass1234', + first_name='Out', + last_name='Sider', + date_of_birth='1994-04-04', + ) + self.client.force_authenticate(outsider) + response = self.client.get('/api/training-file/') + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 0) + + def test_training_file_create_requires_role_uuid(self): + self.client.force_authenticate(self.owner) + uploaded = SimpleUploadedFile('new.txt', b'new body', content_type='text/plain') + response = self.client.post('/api/training-file/', { + 'file': uploaded, + 'file_name': 'new.txt', + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn('role_uuid', response.json()) + + def test_training_file_create_by_owner_succeeds(self): + self.client.force_authenticate(self.owner) + uploaded = SimpleUploadedFile('owner-ok.txt', b'owner file', content_type='text/plain') + response = self.client.post('/api/training-file/', { + 'role_uuid': str(self.role.uuid), + 'file': uploaded, + 'file_name': 'owner-ok.txt', + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + def test_training_file_destroy_forbidden_for_regular_member(self): + self.client.force_authenticate(self.member) + response = self.client.delete(f'/api/training-file/{self.training_file.uuid}/') + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_training_file_destroy_allowed_for_org_manager_member(self): + manager_member = User.objects.create_user( + email_address='manager-member-k@example.com', + password='pass1234', + first_name='Manager', + last_name='Member', + date_of_birth='1995-05-05', + is_manager=True, + ) + self.org.members.add(manager_member) + + self.client.force_authenticate(manager_member) + response = self.client.delete(f'/api/training-file/{self.training_file.uuid}/') + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) diff --git a/apps/onboarding/tests/test_api.py b/apps/onboarding/tests/test_api.py index ef04678..09fd9bc 100644 --- a/apps/onboarding/tests/test_api.py +++ b/apps/onboarding/tests/test_api.py @@ -1,10 +1,13 @@ +from unittest.mock import patch + from django.contrib.auth import get_user_model from django.test import TestCase -from rest_framework import status +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.test import APIClient from apps.accounts.models import Organization, Role from apps.onboarding.models import AgentConfig, AgentInteractionLog, OnboardingFlow, OnboardingSession +from apps.onboarding.viewsets import OnboardingSessionViewSet User = get_user_model() @@ -59,32 +62,30 @@ class OnboardingApiTests(TestCase): def test_agent_config_list_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/agent-config/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_agent_config_create_path(self): self.client.force_authenticate(self.manager) - self.client.raise_request_exception = False response = self.client.post('/api/agent-config/', { - 'organization': str(self.org.uuid), + 'organization_uuid': str(self.org.uuid), 'name': 'Coordinator Monitor', 'agent_type': 'monitor', 'system_prompt': 'Monitor progress', 'llm_config': {'model': 'local'}, 'tool_permissions': ['read'], }, format='json') - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.status_code, HTTP_201_CREATED) def test_agent_config_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/agent-config/{self.agent_config.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_agent_config_update_path(self): self.client.force_authenticate(self.manager) response = self.client.put( f'/api/agent-config/{self.agent_config.uuid}/', { - 'organization': str(self.org.uuid), 'name': 'Coordinator Knowledge Updated', 'agent_type': 'knowledge', 'system_prompt': 'Updated', @@ -93,7 +94,7 @@ class OnboardingApiTests(TestCase): }, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_agent_config_partial_update_path(self): self.client.force_authenticate(self.manager) @@ -102,7 +103,7 @@ class OnboardingApiTests(TestCase): {'name': 'Coordinator Knowledge Patched'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_agent_config_destroy_path(self): self.client.force_authenticate(self.manager) @@ -112,28 +113,27 @@ class OnboardingApiTests(TestCase): agent_type='monitor', ) response = self.client.delete(f'/api/agent-config/{deletable.uuid}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_onboarding_flow_list_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/onboarding-flow/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_flow_create_path(self): self.client.force_authenticate(self.manager) - self.client.raise_request_exception = False response = self.client.post('/api/onboarding-flow/', { 'title': 'New Flow', - 'role': str(self.role.uuid), + 'role_uuid': str(self.role.uuid), 'structure': [], 'is_active': True, }, format='json') - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.status_code, HTTP_201_CREATED) def test_onboarding_flow_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/onboarding-flow/{self.flow.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_flow_update_path(self): self.client.force_authenticate(self.manager) @@ -141,13 +141,12 @@ class OnboardingApiTests(TestCase): f'/api/onboarding-flow/{self.flow.uuid}/', { 'title': 'Coordinator Flow Updated', - 'role': str(self.role.uuid), 'structure': [{'uuid': 'page-2'}], 'is_active': True, }, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_flow_partial_update_path(self): self.client.force_authenticate(self.manager) @@ -156,27 +155,56 @@ class OnboardingApiTests(TestCase): {'title': 'Coordinator Flow Patched'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_flow_destroy_path(self): self.client.force_authenticate(self.manager) delete_flow = OnboardingFlow.objects.create(title='Delete Flow', role=self.role, structure=[]) response = self.client.delete(f'/api/onboarding-flow/{delete_flow.uuid}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_onboarding_flow_start_session_path(self): + self.role.members.add(self.member) self.client.force_authenticate(self.member) response = self.client.post(f'/api/onboarding-flow/{self.flow.uuid}/start-session/') - self.assertIn(response.status_code, (status.HTTP_200_OK, status.HTTP_201_CREATED)) + self.assertIn(response.status_code, (HTTP_200_OK, HTTP_201_CREATED)) + + def test_onboarding_flow_start_session_requires_role_membership_for_regular_users(self): + self.client.force_authenticate(self.member) + response = self.client.post(f'/api/onboarding-flow/{self.flow.uuid}/start-session/') + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertIn('Join this role before starting onboarding.', response.data.get('error', '')) + + def test_onboarding_flow_start_session_allows_manager_without_role_membership(self): + self.client.force_authenticate(self.manager) + response = self.client.post(f'/api/onboarding-flow/{self.flow.uuid}/start-session/') + self.assertIn(response.status_code, (HTTP_200_OK, HTTP_201_CREATED)) + + def test_onboarding_flow_start_session_is_user_specific(self): + self.role.members.add(self.member) + self.client.force_authenticate(self.manager) + response = self.client.post(f'/api/onboarding-flow/{self.flow.uuid}/start-session/') + + self.assertIn(response.status_code, (HTTP_200_OK, HTTP_201_CREATED)) + self.assertNotEqual(response.data.get('uuid'), str(self.session.uuid)) + self.assertEqual(response.data.get('user', {}).get('uuid'), str(self.manager.uuid)) + + def test_onboarding_flow_start_session_reuses_existing_user_session(self): + self.role.members.add(self.member) + self.client.force_authenticate(self.member) + response = self.client.post(f'/api/onboarding-flow/{self.flow.uuid}/start-session/') + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.data.get('uuid'), str(self.session.uuid)) + self.assertEqual(response.data.get('user', {}).get('uuid'), str(self.member.uuid)) def test_onboarding_session_list_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/onboarding-session/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_session_create_path(self): self.client.force_authenticate(self.member) - self.client.raise_request_exception = False response = self.client.post('/api/onboarding-session/', { 'user': str(self.member.uuid), 'role': str(self.role.uuid), @@ -184,12 +212,12 @@ class OnboardingApiTests(TestCase): 'state': {'progress': 0}, 'active_configs': {}, }, format='json') - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_onboarding_session_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/onboarding-session/{self.session.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_session_update_path(self): self.client.force_authenticate(self.member) @@ -204,7 +232,7 @@ class OnboardingApiTests(TestCase): }, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_session_partial_update_path(self): self.client.force_authenticate(self.member) @@ -213,7 +241,7 @@ class OnboardingApiTests(TestCase): {'status': 'paused'}, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_onboarding_session_destroy_path(self): self.client.force_authenticate(self.member) @@ -224,7 +252,7 @@ class OnboardingApiTests(TestCase): active_configs={}, ) response = self.client.delete(f'/api/onboarding-session/{deletable.uuid}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_onboarding_session_interact_path(self): self.client.force_authenticate(self.member) @@ -237,24 +265,373 @@ class OnboardingApiTests(TestCase): }, format='json', ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_onboarding_session_interact_tracks_page_visit_without_response_log(self): + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'fields': [], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + self.session.state = {'flow_uuid': str(self.flow.uuid)} + self.session.save(update_fields=['state', 'updated_at']) + + existing_log_count = AgentInteractionLog.objects.filter(session=self.session).count() + + self.client.force_authenticate(self.member) + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/interact/', + { + 'page_uuid': 'page-1', + }, + format='json', + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.data['session_state'].get('last_page_uuid'), 'page-1') + self.assertIn('page-1', response.data['session_state'].get('visited_pages', [])) + self.assertEqual(AgentInteractionLog.objects.filter(session=self.session).count(), existing_log_count) + + def test_onboarding_session_interact_stores_page_responses_without_assessment(self): + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'fields': [ + { + 'uuid': 'field-1', + 'key': 'q1', + 'label': 'Question 1', + 'field_type': 'select', + 'required': True, + 'options': ['A', 'B', 'C'], + 'validation': {'correct_option': 'B', 'explanation': 'B is correct'}, + } + ], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/interact/', + { + 'page_uuid': 'page-1', + 'responses': {'q1': 'B'}, + }, + format='json', + ) + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertNotIn('assessment', response.data) + self.assertIn('session_state', response.data) + + def test_onboarding_session_interact_does_not_mark_final_quiz_completed(self): + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'fields': [], + }, + { + 'uuid': 'quiz-1', + 'title': 'Final Assessment Quiz', + 'meta': {'page_type': 'final_quiz', 'pass_mark': 80}, + 'fields': [ + { + 'uuid': 'field-1', + 'key': 'q1', + 'label': 'Question 1', + 'field_type': 'select', + 'required': True, + 'options': ['A', 'B', 'C'], + 'validation': {'correct_option': 'B', 'explanation': 'B is correct'}, + } + ], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + self.session.state = {'flow_uuid': str(self.flow.uuid), 'completed_modules': ['page-1']} + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/interact/', + { + 'page_uuid': 'quiz-1', + 'responses': {'q1': 'B'}, + }, + format='json', + ) + self.assertEqual(response.status_code, HTTP_200_OK) + completed = response.data['session_state'].get('completed_modules', []) + self.assertNotIn('quiz-1', completed) + + def test_onboarding_session_ask_ka_path(self): + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'body': 'Base onboarding content', + 'fields': [], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + self.session.state = {'flow_uuid': str(self.flow.uuid)} + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/ask-ka/', + { + 'page_uuid': 'page-1', + 'message': 'Can you explain this section in simpler terms?', + 'mode': 'separate', + }, + format='json', + ) + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.data['status'], 'ok') + self.assertTrue(bool(response.data['answer'])) + self.assertIn('page_help', response.data['session_state']) + self.assertIn('page-1', response.data['session_state']['page_help']) + + def test_onboarding_session_ask_ka_update_page_rewrites_body(self): + original_body = "## Intro\n\nOriginal onboarding content" + revised_body = "## Intro\n\nRevised onboarding content with integrated clarification" + + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'body': original_body, + 'fields': [], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + self.session.state = {'flow_uuid': str(self.flow.uuid)} + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + with patch.object(OnboardingSessionViewSet, '_run_ka_page_revision', return_value=revised_body): + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/ask-ka/', + { + 'page_uuid': 'page-1', + 'message': 'Please make this clearer for beginners.', + 'mode': 'update_page', + }, + format='json', + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertTrue(response.data['updated_page']) + + self.flow.refresh_from_db() + page = self.flow.structure[0] + self.assertEqual(page.get('body'), revised_body) + self.assertNotIn('### Clarification', str(page.get('body') or '')) + + def test_onboarding_session_complete_blocks_when_quiz_score_below_pass_mark(self): + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'fields': [], + }, + { + 'uuid': 'quiz-1', + 'title': 'Final Assessment Quiz', + 'meta': {'page_type': 'final_quiz', 'pass_mark': 80}, + 'fields': [ + { + 'uuid': 'field-1', + 'key': 'q1', + 'label': 'Question 1', + 'field_type': 'select', + 'required': True, + 'options': ['A', 'B', 'C'], + 'validation': {'correct_option': 'B', 'explanation': 'B is correct'}, + } + ], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + self.session.state = { + 'flow_uuid': str(self.flow.uuid), + 'responses': { + 'quiz-1': {'q1': 'A'} + }, + } + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/complete/', + format='json', + ) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['quiz_result']['score_percentage'], 0) + self.assertEqual(response.data['quiz_result']['pass_mark'], 80) + self.assertFalse(response.data['quiz_result']['passed']) + + def test_onboarding_session_complete_path(self): + self.flow.structure = [ + { + 'uuid': 'page-1', + 'title': 'Module 1', + 'fields': [], + }, + { + 'uuid': 'quiz-1', + 'title': 'Final Assessment Quiz', + 'meta': {'page_type': 'final_quiz', 'pass_mark': 80}, + 'fields': [ + { + 'uuid': 'field-1', + 'key': 'q1', + 'label': 'Question 1', + 'field_type': 'select', + 'required': True, + 'options': ['A', 'B', 'C'], + 'validation': {'correct_option': 'B', 'explanation': 'B is correct'}, + } + ], + } + ] + self.flow.save(update_fields=['structure', 'updated_at']) + self.session.state = { + 'flow_uuid': str(self.flow.uuid), + 'responses': { + 'quiz-1': {'q1': 'B'} + }, + } + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post(f'/api/onboarding-session/{self.session.uuid}/complete/') + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.data['quiz_result']['score_percentage'], 100) + self.assertTrue(response.data['quiz_result']['passed']) def test_onboarding_session_history_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/onboarding-session/{self.session.uuid}/history/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_onboarding_session_complete_path(self): - self.client.force_authenticate(self.member) - response = self.client.post(f'/api/onboarding-session/{self.session.uuid}/complete/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_agent_interaction_log_list_path(self): self.client.force_authenticate(self.member) response = self.client.get('/api/agent-interaction-log/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) def test_agent_interaction_log_retrieve_path(self): self.client.force_authenticate(self.member) response = self.client.get(f'/api/agent-interaction-log/{self.log.uuid}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_onboarding_flow_create_rejects_invalid_is_active(self): + self.client.force_authenticate(self.manager) + response = self.client.post('/api/onboarding-flow/', { + 'title': 'Bad Bool Flow', + 'role_uuid': str(self.role.uuid), + 'structure': [], + 'is_active': 'not-a-bool', + }, format='json') + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn('is_active', response.json()) + + def test_onboarding_flow_create_accepts_false_string(self): + self.client.force_authenticate(self.manager) + response = self.client.post('/api/onboarding-flow/', { + 'title': 'Disabled Flow', + 'role_uuid': str(self.role.uuid), + 'structure': [], + 'is_active': 'false', + }, format='json') + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertFalse(response.data.get('is_active')) + + def test_onboarding_flow_partial_update_rejects_invalid_structure(self): + self.client.force_authenticate(self.manager) + response = self.client.patch( + f'/api/onboarding-flow/{self.flow.uuid}/', + {'structure': 'not-a-list'}, + format='json', + ) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn('structure', response.data) + + def test_onboarding_flow_destroy_removes_sessions_for_role(self): + self.client.force_authenticate(self.manager) + flow_to_delete = OnboardingFlow.objects.create( + title='Delete Me and Sessions', + role=self.role, + structure=[], + ) + session = OnboardingSession.objects.create( + user=self.member, + role=self.role, + state={'flow_uuid': str(flow_to_delete.uuid)}, + active_configs={}, + ) + + response = self.client.delete(f'/api/onboarding-flow/{flow_to_delete.uuid}/') + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + self.assertFalse(OnboardingSession.objects.filter(uuid=session.uuid).exists()) + + def test_onboarding_flow_start_session_updates_flow_uuid_on_existing_session(self): + self.role.members.add(self.member) + replacement_flow = OnboardingFlow.objects.create( + title='Replacement Flow', + role=self.role, + structure=[{'uuid': 'page-x'}], + ) + + self.session.state = {'flow_uuid': str(self.flow.uuid)} + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post(f'/api/onboarding-flow/{replacement_flow.uuid}/start-session/') + self.assertEqual(response.status_code, HTTP_200_OK) + self.session.refresh_from_db() + self.assertEqual(self.session.state.get('flow_uuid'), str(replacement_flow.uuid)) + + def test_agent_config_create_requires_organization_uuid(self): + self.client.force_authenticate(self.manager) + response = self.client.post('/api/agent-config/', { + 'name': 'No Org Config', + 'agent_type': 'monitor', + }, format='json') + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn('organization_uuid', response.data) + + def test_agent_config_create_forbidden_for_non_manager_member(self): + self.client.force_authenticate(self.member) + response = self.client.post('/api/agent-config/', { + 'organization_uuid': str(self.org.uuid), + 'name': 'Member Cannot Create', + 'agent_type': 'monitor', + }, format='json') + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_onboarding_session_ask_ka_requires_page_uuid_and_message(self): + self.client.force_authenticate(self.member) + response = self.client.post( + f'/api/onboarding-session/{self.session.uuid}/ask-ka/', + {'page_uuid': 'page-1'}, + format='json', + ) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_onboarding_session_complete_returns_error_when_no_flow_exists(self): + OnboardingFlow.objects.filter(role=self.role).delete() + self.session.state = {'flow_uuid': str(self.flow.uuid), 'responses': {}} + self.session.save(update_fields=['state', 'updated_at']) + + self.client.force_authenticate(self.member) + response = self.client.post(f'/api/onboarding-session/{self.session.uuid}/complete/') + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) diff --git a/apps/onboarding/tests/test_consumers.py b/apps/onboarding/tests/test_consumers.py new file mode 100644 index 0000000..707d38a --- /dev/null +++ b/apps/onboarding/tests/test_consumers.py @@ -0,0 +1,89 @@ +from asgiref.sync import async_to_sync +from django.contrib.auth import get_user_model +from django.test import TestCase + +from apps.accounts.models import Organization, Role +from apps.onboarding.consumers import OnboardingConsumer +from apps.onboarding.models import AgentConfig + +User = get_user_model() + + +class OnboardingConsumerConfigSelectionTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email_address='consumer-test@example.com', + password='pass1234', + first_name='Consumer', + last_name='Tester', + date_of_birth='1992-02-02', + is_manager=True, + ) + self.org = Organization.objects.create(name='Consumer Test Org', owner=self.user) + self.org.members.add(self.user) + + self.quant_role = Role.objects.create(name='Quant Role Consumer', organization=self.org) + self.ux_role = Role.objects.create(name='UX Role Consumer', organization=self.org) + + self.consumer = OnboardingConsumer() + + def test_get_config_by_type_prefers_exact_role(self): + quant_cfg = AgentConfig.objects.create( + organization=self.org, + role=self.quant_role, + name='Quant Curriculum Override', + agent_type='curriculum', + system_prompt='Quant-specific prompt', + ) + AgentConfig.objects.create( + organization=self.org, + role=self.ux_role, + name='UX Curriculum Override', + agent_type='curriculum', + system_prompt='UX-specific prompt', + ) + + selected = async_to_sync(self.consumer.get_config_by_type)(str(self.quant_role.uuid), 'curriculum') + + self.assertIsNotNone(selected) + self.assertEqual(selected.uuid, quant_cfg.uuid) + self.assertEqual(selected.role_id, self.quant_role.id) + + def test_get_config_by_type_falls_back_to_org_default(self): + AgentConfig.objects.filter(role=self.quant_role, agent_type='monitor').delete() + + org_default = AgentConfig.objects.create( + organization=self.org, + role=None, + name='Org Monitor Default', + agent_type='monitor', + system_prompt='Organization-level monitor prompt', + ) + + selected = async_to_sync(self.consumer.get_config_by_type)(str(self.quant_role.uuid), 'monitor') + + self.assertIsNotNone(selected) + self.assertEqual(selected.uuid, org_default.uuid) + self.assertIsNone(selected.role) + + def test_extract_json_list_supports_wrapped_questions_payload(self): + payload = ( + "Here is your quiz output:\n" + "```json\n" + '{"questions": [{"key": "q1", "label": "Question?", "field_type": "select", "options": ["A", "B"], "required": true, "validation": {"correct_option": "A", "explanation": "A"}}]}\n' + "```" + ) + + extracted = self.consumer._extract_json_list(payload) + + self.assertIsInstance(extracted, list) + self.assertEqual(len(extracted), 1) + self.assertEqual(extracted[0]['key'], 'q1') + + def test_build_fallback_quiz_fields_generates_eight_valid_questions(self): + fallback = self.consumer._build_fallback_quiz_fields(['Topic A', 'Topic B']) + + self.assertEqual(len(fallback), 8) + self.assertTrue(all(item.get('field_type') == 'select' for item in fallback)) + self.assertTrue(all(len(item.get('options', [])) >= 4 for item in fallback)) + self.assertTrue(all(item.get('validation', {}).get('correct_option') in item.get('options', []) for item in fallback))