Add application support teams with contacts
Replaces the legacy supportteams/appowners pair: supportteams (teamname unique, teamurl ServiceNow link) + supportteamcontacts (multiple named contacts with SSO per team, the people you reach out to), applications.supportteamid intact. Migration 7d18 migrates each legacy team owner into a contact, drops appowners, and has a validated downgrade. New /api/supportteams CRUD (admin writes, import-mode timestamps, teamname lookup), Support card on application detail, contacts column on the list, and a settings management page. IMPORT-API.md mapping updated to the concrete endpoints. 658 tests pass; live dev migration applied (24 teams / 24 contacts); fresh-install and downgrade round-trips verified on scratch DBs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
234
tests/test_core/test_supportteams.py
Normal file
234
tests/test_core/test_supportteams.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Support teams + contacts: team/contact CRUD, lookup, delete-guard, import.
|
||||
|
||||
The application/support-team wiring: a team owns ordered contacts, an
|
||||
application carries one team, and its payload flattens the team name/url +
|
||||
active contacts so the frontend needs a single call.
|
||||
"""
|
||||
|
||||
IMPORT_HEADER = {'X-Import-Mode': 'true'}
|
||||
LEGACY_CREATED = '2020-01-05 08:30:00'
|
||||
LEGACY_MODIFIED = '2021-06-07T14:15:16'
|
||||
|
||||
|
||||
def _import_headers(auth_headers):
|
||||
merged = dict(auth_headers)
|
||||
merged.update(IMPORT_HEADER)
|
||||
return merged
|
||||
|
||||
|
||||
def _create_team(client, auth_headers, teamname='Controls', teamurl=None):
|
||||
return client.post('/api/supportteams',
|
||||
json={'teamname': teamname, 'teamurl': teamurl},
|
||||
headers=auth_headers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_and_get_team(client, db, auth_headers):
|
||||
"""Create a team, then fetch it back with an (empty) contacts list."""
|
||||
resp = _create_team(client, auth_headers, 'Controls',
|
||||
'https://servicenow.example/group/controls')
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
teamid = resp.get_json()['data']['supportteamid']
|
||||
|
||||
got = client.get(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert got.status_code == 200
|
||||
data = got.get_json()['data']
|
||||
assert data['teamname'] == 'Controls'
|
||||
assert data['teamurl'] == 'https://servicenow.example/group/controls'
|
||||
assert data['contacts'] == []
|
||||
|
||||
|
||||
def test_create_team_requires_teamname(client, db, auth_headers):
|
||||
resp = client.post('/api/supportteams', json={}, headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_team_duplicate_conflict(client, db, auth_headers):
|
||||
_create_team(client, auth_headers, 'Controls')
|
||||
dup = _create_team(client, auth_headers, 'Controls')
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_update_team(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
resp = client.put(f'/api/supportteams/{teamid}',
|
||||
json={'teamname': 'Controls Renamed',
|
||||
'teamurl': 'https://x.example'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['teamname'] == 'Controls Renamed'
|
||||
|
||||
|
||||
def test_delete_team(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'ToDelete').get_json()['data']['supportteamid']
|
||||
resp = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
gone = client.get(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert gone.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# teamname exact-match lookup (import recipe) + active filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_teamname_lookup_filter(client, db, auth_headers):
|
||||
for name in ('Controls', 'ControlsB', 'Networking'):
|
||||
_create_team(client, auth_headers, name)
|
||||
listed = client.get('/api/supportteams?teamname=Controls', headers=auth_headers)
|
||||
assert listed.status_code == 200
|
||||
rows = listed.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['teamname'] == 'Controls'
|
||||
|
||||
|
||||
def test_list_active_filter_hides_inactive(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Retired').get_json()['data']['supportteamid']
|
||||
client.put(f'/api/supportteams/{teamid}', json={'isactive': False},
|
||||
headers=auth_headers)
|
||||
active = client.get('/api/supportteams', headers=auth_headers)
|
||||
assert all(t['teamname'] != 'Retired' for t in active.get_json()['data'])
|
||||
allteams = client.get('/api/supportteams?active=false', headers=auth_headers)
|
||||
assert any(t['teamname'] == 'Retired' for t in allteams.get_json()['data'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete-with-applications 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_team_with_applications_conflicts(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'InUse').get_json()['data']['supportteamid']
|
||||
appresp = client.post('/api/applications',
|
||||
json={'appname': 'DependentApp',
|
||||
'supportteamid': teamid},
|
||||
headers=auth_headers)
|
||||
assert appresp.status_code == 201, appresp.get_json()
|
||||
|
||||
conflict = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert conflict.status_code == 409
|
||||
# Still there.
|
||||
assert client.get(f'/api/supportteams/{teamid}',
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested contact CRUD + ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_contact_crud_and_ordering(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
|
||||
# Add two contacts out of sort order.
|
||||
c2 = client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'Second', 'sso': '222', 'sortorder': 2},
|
||||
headers=auth_headers)
|
||||
assert c2.status_code == 201, c2.get_json()
|
||||
c1 = client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'First', 'sso': '111', 'sortorder': 1},
|
||||
headers=auth_headers)
|
||||
assert c1.status_code == 201
|
||||
|
||||
# Team now nests active contacts in sortorder.
|
||||
team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data']
|
||||
names = [c['name'] for c in team['contacts']]
|
||||
assert names == ['First', 'Second']
|
||||
|
||||
# Update one contact.
|
||||
contactid = c1.get_json()['data']['contactid']
|
||||
upd = client.put(f'/api/supportteams/{teamid}/contacts/{contactid}',
|
||||
json={'name': 'First Updated'}, headers=auth_headers)
|
||||
assert upd.status_code == 200
|
||||
assert upd.get_json()['data']['name'] == 'First Updated'
|
||||
|
||||
# Delete the other contact.
|
||||
otherid = c2.get_json()['data']['contactid']
|
||||
dele = client.delete(f'/api/supportteams/{teamid}/contacts/{otherid}',
|
||||
headers=auth_headers)
|
||||
assert dele.status_code == 200
|
||||
team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data']
|
||||
assert [c['name'] for c in team['contacts']] == ['First Updated']
|
||||
|
||||
|
||||
def test_contact_requires_name(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
resp = client.post(f'/api/supportteams/{teamid}/contacts', json={},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_delete_team_cascades_contacts(client, db, auth_headers):
|
||||
from shopdb.core.models import SupportTeamContact
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'Someone'}, headers=auth_headers)
|
||||
client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert SupportTeamContact.query.filter_by(supportteamid=teamid).count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application carries the team + flattened contacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_application_carries_supportteam_payload(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls',
|
||||
'https://sn.example/controls').get_json()['data']['supportteamid']
|
||||
client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'Alice', 'sso': 'a01', 'sortorder': 0},
|
||||
headers=auth_headers)
|
||||
|
||||
appresp = client.post('/api/applications',
|
||||
json={'appname': 'TeamApp', 'supportteamid': teamid},
|
||||
headers=auth_headers)
|
||||
appid = appresp.get_json()['data']['appid']
|
||||
|
||||
got = client.get(f'/api/applications/{appid}', headers=auth_headers)
|
||||
data = got.get_json()['data']
|
||||
assert data['supportteamid'] == teamid
|
||||
assert data['supportteamname'] == 'Controls'
|
||||
assert data['teamurl'] == 'https://sn.example/controls'
|
||||
assert data['contacts'] == [{'name': 'Alice', 'sso': 'a01'}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import-mode timestamps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_import_mode_preserves_team_timestamps(client, db, auth_headers):
|
||||
resp = client.post('/api/supportteams',
|
||||
json={'teamname': 'Legacy',
|
||||
'createddate': LEGACY_CREATED,
|
||||
'modifieddate': LEGACY_MODIFIED},
|
||||
headers=_import_headers(auth_headers))
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
from shopdb.core.models import SupportTeam
|
||||
team = SupportTeam.query.filter_by(teamname='Legacy').first()
|
||||
assert team.createddate.year == 2020 and team.createddate.month == 1
|
||||
assert team.modifieddate.year == 2021
|
||||
|
||||
|
||||
def test_import_mode_preserves_contact_timestamps(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
resp = client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'LegacyOwner',
|
||||
'createddate': LEGACY_CREATED,
|
||||
'modifieddate': LEGACY_MODIFIED},
|
||||
headers=_import_headers(auth_headers))
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
from shopdb.core.models import SupportTeamContact
|
||||
contact = SupportTeamContact.query.filter_by(name='LegacyOwner').first()
|
||||
assert contact.createddate.year == 2020
|
||||
assert contact.modifieddate.year == 2021
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authz (belt-and-suspenders; the sweep in test_authz also covers these)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_member_cannot_create_team(client, db, member_headers):
|
||||
resp = client.post('/api/supportteams', json={'teamname': 'X'},
|
||||
headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user