|
6 | 6 | from django.test import RequestFactory |
7 | 7 | from django.test.utils import override_settings |
8 | 8 | from django.utils import timezone |
| 9 | +from rest_framework.exceptions import ErrorDetail |
9 | 10 |
|
10 | 11 | from sentry.analytics.events.cron_monitor_created import CronMonitorCreated, FirstCronMonitorCreated |
11 | | -from sentry.constants import ObjectStatus |
| 12 | +from sentry.constants import DataCategory, ObjectStatus |
12 | 13 | from sentry.models.rule import Rule, RuleSource |
13 | | -from sentry.monitors.models import Monitor, MonitorStatus, ScheduleType |
| 14 | +from sentry.monitors.models import Monitor, MonitorStatus, ScheduleType, get_cron_monitor |
| 15 | +from sentry.monitors.types import DATA_SOURCE_CRON_MONITOR |
14 | 16 | from sentry.monitors.validators import ( |
15 | 17 | MonitorDataSourceValidator, |
16 | 18 | MonitorIncidentDetectorValidator, |
17 | 19 | MonitorValidator, |
18 | 20 | ) |
| 21 | +from sentry.quotas.base import SeatAssignmentResult |
19 | 22 | from sentry.testutils.cases import MonitorTestCase |
20 | 23 | from sentry.testutils.helpers.analytics import assert_any_analytics_event |
21 | 24 | from sentry.types.actor import Actor |
22 | 25 | from sentry.utils.outcomes import Outcome |
23 | 26 | from sentry.utils.slug import DEFAULT_SLUG_ERROR_MESSAGE |
24 | | -from sentry.workflow_engine.models import DataConditionGroup |
| 27 | +from sentry.workflow_engine.models import DataConditionGroup, DataSource, DataSourceDetector |
25 | 28 |
|
26 | 29 |
|
27 | 30 | class MonitorValidatorCreateTest(MonitorTestCase): |
@@ -166,7 +169,6 @@ def test_monitor_organization_limit(self): |
166 | 169 | } |
167 | 170 | validator = MonitorValidator(data=data, context=self.context) |
168 | 171 | assert not validator.is_valid() |
169 | | - from rest_framework.exceptions import ErrorDetail |
170 | 172 |
|
171 | 173 | assert validator.errors["nonFieldErrors"] == [ |
172 | 174 | ErrorDetail( |
@@ -1123,3 +1125,203 @@ def test_create_detector_validates_data_source(self): |
1123 | 1125 | assert validator.is_valid(), validator.errors |
1124 | 1126 | assert "_creator" in validator.validated_data["data_sources"][0] |
1125 | 1127 | assert validator.validated_data["data_sources"][0]["data_source_type"] == "cron_monitor" |
| 1128 | + |
| 1129 | + @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.ACCEPTED) |
| 1130 | + def test_create_enabled_assigns_seat(self, mock_assign_seat): |
| 1131 | + """Test that creating an enabled detector assigns a billing seat.""" |
| 1132 | + |
| 1133 | + condition_group = DataConditionGroup.objects.create( |
| 1134 | + organization_id=self.organization.id, |
| 1135 | + logic_type=DataConditionGroup.Type.ANY, |
| 1136 | + ) |
| 1137 | + context = {**self.context, "condition_group": condition_group} |
| 1138 | + validator = MonitorIncidentDetectorValidator( |
| 1139 | + data=self.valid_data, |
| 1140 | + context=context, |
| 1141 | + ) |
| 1142 | + assert validator.is_valid(), validator.errors |
| 1143 | + detector = validator.save() |
| 1144 | + |
| 1145 | + detector.refresh_from_db() |
| 1146 | + assert detector.enabled is True |
| 1147 | + |
| 1148 | + # Verify seat was assigned |
| 1149 | + monitor = get_cron_monitor(detector) |
| 1150 | + mock_assign_seat.assert_called_with(DataCategory.MONITOR, monitor) |
| 1151 | + |
| 1152 | + @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.RATE_LIMITED) |
| 1153 | + def test_create_enabled_no_seat_available(self, mock_assign_seat): |
| 1154 | + """ |
| 1155 | + Test that creating a detector with no seats available creates it but |
| 1156 | + leaves it disabled. |
| 1157 | + """ |
| 1158 | + condition_group = DataConditionGroup.objects.create( |
| 1159 | + organization_id=self.organization.id, |
| 1160 | + logic_type=DataConditionGroup.Type.ANY, |
| 1161 | + ) |
| 1162 | + context = {**self.context, "condition_group": condition_group} |
| 1163 | + validator = MonitorIncidentDetectorValidator( |
| 1164 | + data=self.valid_data, |
| 1165 | + context=context, |
| 1166 | + ) |
| 1167 | + assert validator.is_valid(), validator.errors |
| 1168 | + detector = validator.save() |
| 1169 | + |
| 1170 | + detector.refresh_from_db() |
| 1171 | + # Detector created but not enabled due to no seat assignment |
| 1172 | + assert detector.enabled is False |
| 1173 | + monitor = get_cron_monitor(detector) |
| 1174 | + assert monitor.status == ObjectStatus.DISABLED |
| 1175 | + |
| 1176 | + # Verify seat assignment was attempted |
| 1177 | + mock_assign_seat.assert_called_with(DataCategory.MONITOR, monitor) |
| 1178 | + |
| 1179 | + @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.ACCEPTED) |
| 1180 | + def test_update_enable_assigns_seat(self, mock_assign_seat): |
| 1181 | + """ |
| 1182 | + Test that enabling a previously disabled detector assigns a seat. |
| 1183 | + """ |
| 1184 | + # Create a disabled detector |
| 1185 | + detector = self.create_detector( |
| 1186 | + project=self.project, |
| 1187 | + name="Test Detector", |
| 1188 | + type="monitor_check_in_failure", |
| 1189 | + enabled=False, |
| 1190 | + ) |
| 1191 | + monitor = self._create_monitor( |
| 1192 | + name="Test Monitor", |
| 1193 | + slug="test-monitor", |
| 1194 | + status=ObjectStatus.DISABLED, |
| 1195 | + ) |
| 1196 | + data_source = DataSource.objects.create( |
| 1197 | + type=DATA_SOURCE_CRON_MONITOR, |
| 1198 | + organization_id=self.organization.id, |
| 1199 | + source_id=str(monitor.id), |
| 1200 | + ) |
| 1201 | + DataSourceDetector.objects.create(data_source=data_source, detector=detector) |
| 1202 | + |
| 1203 | + validator = MonitorIncidentDetectorValidator( |
| 1204 | + instance=detector, data={"enabled": True}, context=self.context, partial=True |
| 1205 | + ) |
| 1206 | + assert validator.is_valid(), validator.errors |
| 1207 | + validator.save() |
| 1208 | + |
| 1209 | + detector.refresh_from_db() |
| 1210 | + monitor.refresh_from_db() |
| 1211 | + assert detector.enabled is True |
| 1212 | + assert monitor.status == ObjectStatus.ACTIVE |
| 1213 | + |
| 1214 | + # Verify seat was assigned |
| 1215 | + mock_assign_seat.assert_called_with(DataCategory.MONITOR, monitor) |
| 1216 | + |
| 1217 | + @patch( |
| 1218 | + "sentry.quotas.backend.check_assign_seat", |
| 1219 | + return_value=SeatAssignmentResult(assignable=False, reason="No seats available"), |
| 1220 | + ) |
| 1221 | + def test_update_enable_no_seat_available(self, mock_check_seat): |
| 1222 | + """ |
| 1223 | + Test that enabling fails with validation error when no seats are |
| 1224 | + available. |
| 1225 | + """ |
| 1226 | + # Create a disabled detector |
| 1227 | + detector = self.create_detector( |
| 1228 | + project=self.project, |
| 1229 | + name="Test Detector", |
| 1230 | + type="monitor_check_in_failure", |
| 1231 | + enabled=False, |
| 1232 | + ) |
| 1233 | + monitor = self._create_monitor( |
| 1234 | + name="Test Monitor", |
| 1235 | + slug="test-monitor", |
| 1236 | + status=ObjectStatus.DISABLED, |
| 1237 | + ) |
| 1238 | + data_source = DataSource.objects.create( |
| 1239 | + type=DATA_SOURCE_CRON_MONITOR, |
| 1240 | + organization_id=self.organization.id, |
| 1241 | + source_id=str(monitor.id), |
| 1242 | + ) |
| 1243 | + DataSourceDetector.objects.create(data_source=data_source, detector=detector) |
| 1244 | + |
| 1245 | + validator = MonitorIncidentDetectorValidator( |
| 1246 | + instance=detector, data={"enabled": True}, context=self.context, partial=True |
| 1247 | + ) |
| 1248 | + |
| 1249 | + # Validation should fail due to no seats available |
| 1250 | + assert not validator.is_valid() |
| 1251 | + assert "enabled" in validator.errors |
| 1252 | + assert validator.errors["enabled"] == ["No seats available"] |
| 1253 | + |
| 1254 | + # Detector and monitor should still be disabled |
| 1255 | + detector.refresh_from_db() |
| 1256 | + monitor.refresh_from_db() |
| 1257 | + assert detector.enabled is False |
| 1258 | + assert monitor.status == ObjectStatus.DISABLED |
| 1259 | + |
| 1260 | + # Verify seat availability check was performed |
| 1261 | + mock_check_seat.assert_called_with(DataCategory.MONITOR, monitor) |
| 1262 | + |
| 1263 | + @patch("sentry.quotas.backend.disable_seat") |
| 1264 | + def test_update_disable_disables_seat(self, mock_disable_seat): |
| 1265 | + """Test that disabling a previously enabled detector disables the seat.""" |
| 1266 | + # Create an enabled detector |
| 1267 | + detector = self.create_detector( |
| 1268 | + project=self.project, |
| 1269 | + name="Test Detector", |
| 1270 | + type="monitor_check_in_failure", |
| 1271 | + enabled=True, |
| 1272 | + ) |
| 1273 | + monitor = self._create_monitor( |
| 1274 | + name="Test Monitor", |
| 1275 | + slug="test-monitor", |
| 1276 | + status=ObjectStatus.ACTIVE, |
| 1277 | + ) |
| 1278 | + data_source = DataSource.objects.create( |
| 1279 | + type=DATA_SOURCE_CRON_MONITOR, |
| 1280 | + organization_id=self.organization.id, |
| 1281 | + source_id=str(monitor.id), |
| 1282 | + ) |
| 1283 | + DataSourceDetector.objects.create(data_source=data_source, detector=detector) |
| 1284 | + |
| 1285 | + validator = MonitorIncidentDetectorValidator( |
| 1286 | + instance=detector, data={"enabled": False}, context=self.context, partial=True |
| 1287 | + ) |
| 1288 | + assert validator.is_valid(), validator.errors |
| 1289 | + validator.save() |
| 1290 | + |
| 1291 | + detector.refresh_from_db() |
| 1292 | + monitor.refresh_from_db() |
| 1293 | + assert detector.enabled is False |
| 1294 | + assert monitor.status == ObjectStatus.DISABLED |
| 1295 | + |
| 1296 | + # Verify disable_seat was called |
| 1297 | + mock_disable_seat.assert_called_with(DataCategory.MONITOR, monitor) |
| 1298 | + |
| 1299 | + @patch("sentry.quotas.backend.remove_seat") |
| 1300 | + def test_delete_removes_seat(self, mock_remove_seat: MagicMock) -> None: |
| 1301 | + """Test that deleting a detector removes its billing seat immediately.""" |
| 1302 | + detector = self.create_detector( |
| 1303 | + project=self.project, |
| 1304 | + name="Test Detector", |
| 1305 | + type="monitor_check_in_failure", |
| 1306 | + enabled=True, |
| 1307 | + ) |
| 1308 | + monitor = self._create_monitor( |
| 1309 | + name="Test Monitor", |
| 1310 | + slug="test-monitor", |
| 1311 | + status=ObjectStatus.ACTIVE, |
| 1312 | + ) |
| 1313 | + data_source = DataSource.objects.create( |
| 1314 | + type=DATA_SOURCE_CRON_MONITOR, |
| 1315 | + organization_id=self.organization.id, |
| 1316 | + source_id=str(monitor.id), |
| 1317 | + ) |
| 1318 | + DataSourceDetector.objects.create(data_source=data_source, detector=detector) |
| 1319 | + |
| 1320 | + validator = MonitorIncidentDetectorValidator( |
| 1321 | + instance=detector, data={}, context=self.context |
| 1322 | + ) |
| 1323 | + |
| 1324 | + validator.delete() |
| 1325 | + |
| 1326 | + # Verify remove_seat was called immediately |
| 1327 | + mock_remove_seat.assert_called_with(DataCategory.MONITOR, monitor) |
0 commit comments