@@ -1152,3 +1152,195 @@ def test_multi_line_placeholders_work():
11521152 )
11531153
11541154 assert recipients .rows [0 ].personalisation ["data" ] == "a\n b\n \n c"
1155+
1156+
1157+ class TestDuplicateRecipients :
1158+ """Duplicate-recipient detection (issue #3319).
1159+
1160+ The detection should be case-insensitive, ignore leading/trailing
1161+ whitespace, and treat phone numbers as equivalent when they normalise to
1162+ the same E.164 form. It should be a non-blocking warning -- ``has_errors``
1163+ must remain ``False`` when the only issue is duplicate recipients.
1164+ """
1165+
1166+ def test_no_duplicates_when_all_emails_unique (self ):
1167+ recipients = RecipientCSV (
1168+ """
1169+ email address
1170+ alice@example.com
1171+ bob@example.com
1172+ carol@example.com
1173+ """ ,
1174+ template_type = "email" ,
1175+ )
1176+ assert recipients .has_duplicate_recipients is False
1177+ assert recipients .count_of_duplicate_recipient_rows == 0
1178+ assert recipients .count_of_unique_duplicate_recipients == 0
1179+ assert list (recipients .rows_with_duplicate_recipients ) == []
1180+ assert recipients .has_errors is False
1181+
1182+ def test_detects_exact_duplicate_emails (self ):
1183+ recipients = RecipientCSV (
1184+ """
1185+ email address
1186+ alice@example.com
1187+ bob@example.com
1188+ alice@example.com
1189+ """ ,
1190+ template_type = "email" ,
1191+ )
1192+ assert recipients .has_duplicate_recipients is True
1193+ assert recipients .count_of_duplicate_recipient_rows == 1
1194+ assert recipients .count_of_unique_duplicate_recipients == 1
1195+ duplicates = list (recipients .rows_with_duplicate_recipients )
1196+ # Only the *second* occurrence is flagged; the first is kept.
1197+ assert [row .index for row in duplicates ] == [2 ]
1198+ # Duplicates are a warning, not a hard error.
1199+ assert recipients .has_errors is False
1200+
1201+ def test_email_dedupe_is_case_insensitive_and_trims_whitespace (self ):
1202+ # Build the CSV explicitly so leading/trailing whitespace and case
1203+ # differences are preserved without tripping the linter.
1204+ file_contents = "email address\n " "Alice@Example.com\n " " alice@example.COM \n " "ALICE@EXAMPLE.COM\n "
1205+ recipients = RecipientCSV (file_contents , template_type = "email" )
1206+ assert recipients .count_of_duplicate_recipient_rows == 2
1207+ assert recipients .count_of_unique_duplicate_recipients == 1
1208+
1209+ def test_counts_unique_duplicate_recipients (self ):
1210+ recipients = RecipientCSV (
1211+ """
1212+ email address
1213+ alice@example.com
1214+ bob@example.com
1215+ alice@example.com
1216+ bob@example.com
1217+ carol@example.com
1218+ bob@example.com
1219+ """ ,
1220+ template_type = "email" ,
1221+ )
1222+ # alice appears twice (1 extra), bob appears 3 times (2 extra) -> 3 duplicate rows
1223+ assert recipients .count_of_duplicate_recipient_rows == 3
1224+ # Two distinct recipients have duplicates.
1225+ assert recipients .count_of_unique_duplicate_recipients == 2
1226+
1227+ def test_detects_duplicate_phone_numbers_in_different_formats (self ):
1228+ recipients = RecipientCSV (
1229+ """
1230+ phone number
1231+ 6502532222
1232+ +1 650-253-2222
1233+ 650 253 2222
1234+ 6502532223
1235+ """ ,
1236+ template_type = "sms" ,
1237+ international_sms = True ,
1238+ )
1239+ assert recipients .count_of_duplicate_recipient_rows == 2
1240+ assert recipients .count_of_unique_duplicate_recipients == 1
1241+
1242+ def test_skips_rows_with_bad_or_missing_recipients (self ):
1243+ file_contents = "email address\n " "alice@example.com\n " "not-an-email\n " "\n " "alice@example.com\n "
1244+ recipients = RecipientCSV (file_contents , template_type = "email" )
1245+ # The blank row and bad-email row should not be considered for dedupe.
1246+ assert recipients .count_of_duplicate_recipient_rows == 1
1247+
1248+ def test_duplicate_detection_disabled_for_letters (self ):
1249+ recipients = RecipientCSV (
1250+ """
1251+ address line 1, address line 2, address line 3, address line 4, address line 5, address line 6, postcode
1252+ A, B, C, , , , X1A0A1
1253+ A, B, C, , , , X1A0A1
1254+ """ ,
1255+ template_type = "letter" ,
1256+ )
1257+ # Letters can legitimately share an address, so we don't flag duplicates.
1258+ assert recipients .has_duplicate_recipients is False
1259+ assert recipients .count_of_duplicate_recipient_rows == 0
1260+ assert recipients .count_of_unique_duplicate_recipients == 0
1261+
1262+ def test_duplicates_do_not_make_has_errors_true (self ):
1263+ recipients = RecipientCSV (
1264+ """
1265+ email address
1266+ alice@example.com
1267+ alice@example.com
1268+ """ ,
1269+ template_type = "email" ,
1270+ )
1271+ assert recipients .has_duplicate_recipients is True
1272+ # Critically: duplicates are a non-blocking warning, not an error.
1273+ assert recipients .has_errors is False
1274+ assert list (recipients .rows_with_errors ) == []
1275+
1276+ def test_ses_simulator_addresses_are_not_flagged_as_duplicates (self ):
1277+ # The ``simulator.amazonses.com`` mailboxes (success@, bounce@,
1278+ # complaint@, etc.) are deliberately re-used for load/smoke testing,
1279+ # so a CSV full of them should not produce a "duplicate recipients"
1280+ # warning.
1281+ recipients = RecipientCSV (
1282+ """
1283+ email address
1284+ success@simulator.amazonses.com
1285+ SUCCESS@simulator.amazonses.com
1286+ bounce@simulator.amazonses.com
1287+ bounce@simulator.amazonses.com
1288+ alice@example.com
1289+ alice@example.com
1290+ """ ,
1291+ template_type = "email" ,
1292+ )
1293+ # Only the real duplicate (alice) is flagged; the simulator addresses
1294+ # are excluded even though they appear multiple times.
1295+ assert recipients .count_of_unique_duplicate_recipients == 1
1296+ assert recipients .count_of_duplicate_recipient_rows == 1
1297+ duplicate_rows = list (recipients .rows_with_duplicate_recipients )
1298+ assert len (duplicate_rows ) == 1
1299+ assert duplicate_rows [0 ].recipient == "alice@example.com"
1300+
1301+ def test_simulator_phone_numbers_are_not_flagged_as_duplicates (self ):
1302+ # The simulator numbers configured in ``notification-api``
1303+ # (``SIMULATED_SMS_NUMBERS``) are short-circuited and never actually
1304+ # delivered, so they are intentionally re-used in load/smoke tests
1305+ # and should not produce a duplicate warning. The exclusion is on the
1306+ # E.164 form, so different input formats of the same simulator number
1307+ # must also be excluded.
1308+ recipients = RecipientCSV (
1309+ """
1310+ phone number
1311+ +16132532222
1312+ 6132532222
1313+ (613) 253-2222
1314+ +16132532223
1315+ +16132532223
1316+ +16135551234
1317+ +16135551234
1318+ """ ,
1319+ template_type = "sms" ,
1320+ )
1321+ # Only the real duplicate (+16135551234) is flagged.
1322+ assert recipients .count_of_unique_duplicate_recipients == 1
1323+ assert recipients .count_of_duplicate_recipient_rows == 1
1324+ duplicate_rows = list (recipients .rows_with_duplicate_recipients )
1325+ assert len (duplicate_rows ) == 1
1326+
1327+ def test_duplicate_summary_is_cached (self ):
1328+ # Re-reading any of the duplicate properties on a large upload should
1329+ # be cheap: the underlying single-pass computation must only run once.
1330+ recipients = RecipientCSV (
1331+ """
1332+ email address
1333+ alice@example.com
1334+ alice@example.com
1335+ """ ,
1336+ template_type = "email" ,
1337+ )
1338+ # Prime the cache.
1339+ first_indices = recipients ._duplicate_recipient_row_indices
1340+ # Subsequent accesses (incl. via different public properties) should
1341+ # all return the exact same cached object.
1342+ assert recipients ._duplicate_recipient_row_indices is first_indices
1343+ assert recipients ._duplicate_recipient_summary .row_indices is first_indices
1344+ assert recipients .count_of_duplicate_recipient_rows == 1
1345+ assert recipients .count_of_unique_duplicate_recipients == 1
1346+ assert recipients .has_duplicate_recipients is True
0 commit comments