1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
require_relative "../../test_helper"
require "json"
require "pathname"
require "rack/test"
require "tmpdir"
require "pro_tacts/web"
# The import screens, exercised the way the other admin screens are:
# real requests through the Roda app against a throwaway store
# (docs/plans/2026-09-21-import-a-vcf.md).
class AdminImportPagesTest < Minitest::Test
include Rack::Test::Methods
include SentryTracing
include ThrowawayContacts
JANE = <<~CARD.gsub("\n", "\r\n")
BEGIN:VCARD
VERSION:3.0
N:Booles;Jane;;;
FN:Jane Booles
TEL;type=CELL:+1 555 0100
item1.X-ABRELATEDNAMES:Sam Booles
item1.X-ABLabel:_$!<Spouse>!$_
X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
UID:ABC-123
END:VCARD
CARD
PLAIN = <<~CARD.gsub("\n", "\r\n")
BEGIN:VCARD
VERSION:3.0
N:Booles;Sam;;;
FN:Sam Booles
UID:DEF-456
END:VCARD
CARD
# Someone the book already has, whom JANE looks like.
STORED_JANE = <<~CARD.gsub("\n", "\r\n")
BEGIN:VCARD
VERSION:3.0
N:Booles;Jane;;;
FN:Jane Booles
EMAIL;type=INTERNET:jane@example.com
UID:jane
END:VCARD
CARD
def app
traced(ProTacts::Web)
end
def setup
header "Remote-User", "test@example.com"
end
# The file as a browser sends one, written out because Rack::Test
# uploads a path. Answers with the import's id, which every screen
# of the walk carries.
def upload(bytes, name: "contacts.vcf")
Dir.mktmpdir do |tmp|
path = Pathname.new(tmp) / name
path.binwrite(bytes)
post "/import", "vcf" => Rack::Test::UploadedFile.new(path.to_s, "text/vcard", true)
end
last_response.headers["location"].to_s[%r{/import/([a-z]+)}, 1]
end
# The snapshot guard the editor's form carries, read off the page
# rather than out of the store, a staged contact having no row
# there yet. An etag wears quotes (RFC 9110 section 8.8.3) and the
# attribute spells them as entities; a browser sends back the
# quotes themselves.
def etag = last_response.body[/name="etag" value="([^"]+)"/, 1].to_s.gsub(""", '"')
# A row opened and its Save pressed, which is the import of that one
# contact: the fields the editor always sends, over the etag the
# page it came from was rendered with, and the boxes that page came
# with sent back the way a browser sends them — a card arrives with
# the groups it comes in under already ticked (Web#import_picker).
# `into` opens the row folded into that contact rather than as a new
# one (Admin::ImportTarget), and the page's own hidden fields go
# back with the rest.
def save(id, index, first:, last:, into: nil, **params)
get "/import/#{id}/#{index}#{"?into=#{into}" if into}"
post "/import/#{id}/#{index}",
{"etag" => etag, "company" => ticked("company").first || "0",
"first" => first, "middle" => "", "last" => last,
"nickname" => "", "note" => "",
"groups" => ticked("groups[]"), "named" => ticked("named[]"),
"into" => hidden("into").first.to_s, "was" => hidden("was[]")}.merge(params)
end
# The values of the boxes the last page rendered ticked.
def ticked(name)
last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked/).flatten
end
# The values of the last page's hidden fields of that name.
def hidden(name)
last_response.body.scan(/<input type="hidden" name="#{Regexp.escape(name)}" value="([^"]*)">/).flatten
end
# The group this import files its arrivals under, named when the
# upload happens: every Save ticks it, so the name is read off the
# staged files themselves — there is no form for it, and no screen
# that states it apart from the boxes beside each card.
def import_group(id)
ProTacts::Import::Staged.saved(id).first
end
def test_the_screen_asks_for_a_vcf
get "/import"
assert_equal 200, last_response.status
assert_includes last_response.body, %(<form action="/import" method="post" enctype="multipart/form-data")
assert_includes last_response.body, %(<input type="file" name="vcf" accept=".vcf,text/vcard,.json,application/json">)
end
# Straight into the walk: a list built from a file nobody has looked
# at yet is a stop on the way to the same place, and it stands
# beside every card anyway.
def test_an_upload_opens_on_the_first_contact_having_written_nothing
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
assert_equal 303, last_response.status
assert_equal "/import/#{id}/0", last_response.headers["location"]
follow_redirect!
assert_equal 200, last_response.status
assert_includes last_response.body, "Jane Booles"
assert_includes last_response.body, "Sam Booles"
assert_includes last_response.body, %(href="/import/#{id}/1")
assert_empty store.changes
end
end
# A file with no contacts in it has no walk to walk: nothing is
# staged, and the screen that asked for the file says why it has it
# again — the reader's own message, the same one any file that will
# not read gets.
def test_a_file_with_no_contacts_in_it_comes_back_unstaged
with_contacts({}) do |store|
id = upload("")
assert_equal 200, last_response.status
assert_includes last_response.body, "this file holds no vCards"
assert_nil id
assert_empty store.changes
end
end
# The row says what opening it would be for, in a diff's own mark: a
# contact losing nothing does not need a visit, and wears nothing.
def test_the_walk_marks_what_each_contact_is_losing
with_contacts({}) do |_store|
upload(JANE + PLAIN)
follow_redirect!
assert_includes last_response.body, %(<span class="type-label diff-removed">-3</span>)
assert_equal 1, last_response.body.scan("diff-removed").length
end
end
# The contacts stand beside whatever is open, rather than on a page
# of their own: opening a row never costs your place in the list.
# The list carries no caption — the rows are self-evidently
# contacts — and the editor's caption row only its action, there
# being no contact to go back to yet.
def test_the_walk_stands_beside_the_card_being_read
with_contacts({}) do |_store|
id = upload(JANE + PLAIN)
get "/import/#{id}/0"
assert_includes last_response.body, %(<div class="walk">)
assert_includes last_response.body, %(<nav class="record walk-list"><ul)
assert_includes last_response.body, "Sam Booles"
assert_includes last_response.body, %(href="/import/#{id}/1")
# The editor's caption row carries only its action, and its
# footer only the submit: the contact this card will become
# does not exist to go back to.
refute_includes last_response.body, %(href="/contacts/0")
assert_includes last_response.body, %(<footer><button type="submit" form="contact-form")
assert_includes last_response.body,
%(<a href="/import/#{id}/0" aria-current="page" data-state="unsaved">)
end
end
# The list reads the way the contacts page does (Contact#sort_key),
# not in the file's order, and the walk goes down it as it reads:
# the upload opens on the first row, and a Save steps to the next.
# The row keeps its place in the file as its name.
def test_the_walk_lists_the_file_the_way_the_contacts_page_does
with_contacts({}) do |_store|
zed = PLAIN.sub("N:Booles;Sam;;;", "N:Zed;Zoe;;;").sub("FN:Sam Booles", "FN:Zoe Zed")
adams = PLAIN.sub("N:Booles;Sam;;;", "N:adams;Al;;;").sub("FN:Sam Booles", "FN:Al adams")
id = upload(zed + JANE + adams)
assert_equal "/import/#{id}/2", last_response.headers["location"]
follow_redirect!
links = last_response.body.scan(%r{<a href="/import/#{id}/(\d+)"}).flatten
assert_equal %w[2 1 0], links
save(id, 2, first: "Al", last: "adams")
assert_equal "/import/#{id}/1", last_response.headers["location"]
end
end
# A Save steps to the next unsaved row below it, so a row skipped
# by opening a later one stays skipped; with nothing unsaved below,
# the walk comes back round to the first one left.
def test_a_save_steps_past_a_skipped_row
with_contacts({}) do |_store|
zed = PLAIN.sub("N:Booles;Sam;;;", "N:Zed;Zoe;;;").sub("FN:Sam Booles", "FN:Zoe Zed")
adams = PLAIN.sub("N:Booles;Sam;;;", "N:adams;Al;;;").sub("FN:Sam Booles", "FN:Al adams")
id = upload(zed + JANE + adams)
# The list reads Al (2), Jane (1), Zoe (0); Al is skipped.
save(id, 1, first: "Jane", last: "Booles")
assert_equal "/import/#{id}/0", last_response.headers["location"]
save(id, 0, first: "Zoe", last: "Zed")
assert_equal "/import/#{id}/2", last_response.headers["location"]
end
end
# Everything is unsaved until its own screen says otherwise, and a
# walk broken off overnight has to say which rows those are. The
# rail says it on screen; the word is there for a reader that
# cannot see one.
def test_a_row_that_is_not_in_the_book_says_so
with_contacts({}) do |_store|
upload(JANE + PLAIN)
follow_redirect!
assert_equal 2, last_response.body.scan(%(data-state="unsaved")).length
assert_equal 2, last_response.body.scan(%(<span class="gl-visually-hidden">not saved</span>)).length
refute_includes last_response.body, %(<span class="type-label">not saved</span>)
end
end
# Every export names the program that wrote it, and nobody is
# going to copy that into a card: it is dropped without being
# counted, so a file losing nothing else reads as losing nothing —
# no row wears a mark, and the struck line on the card itself is
# the whole of what this file is losing.
def test_a_file_losing_only_its_exporters_name_asks_for_nothing
with_contacts({}) do |_store|
upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n"))
follow_redirect!
refute_includes last_response.body, "diff-removed"
end
end
# The card as exported still says so, though: it is the file's own
# bytes, and a line shown plain there would be one claiming to
# arrive.
def test_the_exporters_name_is_struck_on_the_card_it_arrived_on
with_contacts({}) do |store|
id = upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n"))
get "/import/#{id}/0"
assert_includes last_response.body,
%(<li data-dropped><span>PRODID:-//Apple Inc.//macOS 15.0//EN</span>)
save(id, 0, first: "Sam", last: "Booles")
refute_includes store.contacts.fetch(0).vcard.to_s, "PRODID"
end
end
# The two cards: the contact as the file wrote it, and the contact
# that is coming in, open in the editor.
def test_a_contact_opens_beside_the_card_it_arrived_as
with_contacts({}) do |_store|
id = upload(JANE)
get "/import/#{id}/0"
assert_equal 200, last_response.status
assert_includes last_response.body, %(<div class="paired">)
# Every line of the original, struck where it is not coming in.
assert_includes last_response.body, "TEL;type=CELL:+1 555 0100"
assert_includes last_response.body,
%(<li data-dropped><span>X-SOCIALPROFILE;type=twitter:https://twitter.com/jb</span>)
assert_includes last_response.body, "not imported"
# And the editor, pre-filled and pointed at the import.
assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
assert_includes last_response.body, %(value="+1 555 0100")
# The one editor in the app whose Save creates the record.
assert_includes last_response.body, %(>Import</button>)
end
end
# The shape macOS writes a company in (test/fixtures/cards/
# company.vcf): N empty, the name in FN and ORG, and the flag. It
# opens in the company editor and comes in as a company, its name
# ORG's own and FN following it the way macOS writes FN
# (docs/plans/2026-10-07-switching-company-and-person.md).
def test_a_company_card_comes_in_as_a_company
company = <<~CARD.gsub("\n", "\r\n")
BEGIN:VCARD
VERSION:3.0
N:;;;;
FN:Bletchley Park
ORG:Bletchley Park Trust;
X-ABShowAs:COMPANY
UID:GHI-789
END:VCARD
CARD
with_contacts({}) do |store|
id = upload(company)
get "/import/#{id}/0"
refute_includes last_response.body, "not imported"
assert_includes last_response.body, "<span>Organization</span>"
assert_includes last_response.body, %(name="organization" value="Bletchley Park Trust")
save(id, 0, first: "", last: "", company: "1", organization: "Bletchley Park Trust")
assert_equal 303, last_response.status
park = store.contacts.fetch(0)
assert_predicate park, :company?
assert_equal "Bletchley Park Trust;", park.organization
assert_equal "Bletchley Park Trust", park.name
end
end
def test_an_index_past_the_end_of_the_file_is_not_found
with_contacts({}) do |_store|
id = upload(JANE)
get "/import/#{id}/7"
assert_equal 404, last_response.status
end
end
# The Save on a row's own screen is the import of that contact:
# nothing waits for the end of the file.
def test_saving_a_row_puts_that_one_contact_in_the_book
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
assert_equal 303, last_response.status
assert_equal 1, store.contacts.length
jane = store.contacts.fetch(0)
assert_equal "Jane Booles", jane.name
# And the walk steps straight to the next row nobody has read,
# the list beside it saying the row that was saved is in: it
# opens in the walk again, and the row nobody has opened still
# leads into it.
assert_equal "/import/#{id}/1", last_response.headers["location"]
follow_redirect!
assert_includes last_response.body, %(href="/import/#{id}/0")
assert_includes last_response.body, %(data-state="saved")
assert_includes last_response.body, %(<span class="gl-visually-hidden">saved</span>)
# The row's verdict now: the check landed in its circle, and no
# count of losses — they were settled by the save.
assert_includes last_response.body, %(<path d="m9 12 2 2 4-4">)
refute_includes last_response.body, "diff-removed"
end
end
# The point of the pair: read what is being left behind on the
# left, and put what matters into the card on the right.
def test_an_edit_on_the_pair_comes_in_with_the_contact
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles", "note" => "Spouse: Sam Booles")
assert_includes store.contacts.fetch(0).vcard.to_s, "NOTE:Spouse: Sam Booles"
end
end
# A row already in the book is a contact, edited where every other
# contact is: the import's editor writes, and writing it a second
# time would be a second contact rather than an edit of the first.
# The row stays in the walk either way — it opens as the contact it
# became, read beside the card it arrived as, not as a form again.
def test_a_row_already_in_the_book_reads_in_the_walk
with_contacts({}) do |_store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
get "/import/#{id}/0"
assert_equal 200, last_response.status
assert_includes last_response.body, %(<div class="walk">)
# The details it landed with, and the card it arrived as.
assert_includes last_response.body, %(<dl class="detail-grid">)
assert_includes last_response.body, "+1 555 0100"
assert_includes last_response.body, %(data-dropped)
# No editor and no card form to submit; the one write the look
# back offers is the groups dialog, the contact page's own,
# landed back on the row.
refute_includes last_response.body, %(name="etag")
assert_includes last_response.body, %(popovertarget="edit-groups")
# And no "new", which would land on the new group, off the walk.
refute_includes last_response.body, %(/groups/new")
assert_includes last_response.body,
%(<input type="hidden" name="land" value="/import/#{id}/0">)
# And the row's verdict is the check landed in its circle.
assert_includes last_response.body, %(<path d="m9 12 2 2 4-4">)
# The way into edit mode: the contact page's own edit link, in
# the walk's caption row.
assert_includes last_response.body,
%(<a href="/import/#{id}/0/edit" class="btn" data-size="sm">edit</a>)
end
end
# Edit mode for a stored row: the contact's own editor over the
# card it arrived as, posting to the row — where the amend branch
# of the save answers — and landing back on the row's read screen.
# A row still to do is already its editor, so its edit is the row
# screen itself.
def test_a_stored_row_edits_in_the_walk
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
jane = store.contacts.fetch(0)
get "/import/#{id}/0/edit"
assert_equal 200, last_response.status
assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
assert_includes last_response.body, %(name="etag" value="")
assert_includes last_response.body, "Sam Booles"
# No group boxes: the amend is #apply_edit's form, and membership
# is the dialog's question, on the row's read screen.
refute_includes last_response.body, %(name="groups[]")
post "/import/#{id}/0",
"company" => "0", "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "Jay", "note" => ""
assert_equal "/import/#{id}/0", last_response.headers["location"]
jane = store.contacts.fetch(0)
assert_equal "Jay", jane.nickname
# And an unsaved row's edit is the row screen.
get "/import/#{id}/1/edit"
assert_equal 303, last_response.status
assert_equal "/import/#{id}/1", last_response.headers["location"]
end
end
# Membership is asked on the look back too, with the contact page's
# own dialog: the save lands back on the row, and the row answers
# with the tags the write left — a look back not costing the walk
# its place.
def test_a_saved_rows_groups_are_edited_in_the_walk
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
jane = store.contacts.find { it.name == "Jane Booles" }
get "/import/#{id}/0"
joined = ticked("groups[]")
post "/contacts/#{jane.id}/groups",
"groups" => [school, *joined], "was" => joined, "land" => "/import/#{id}/0"
assert_equal 303, last_response.status
assert_equal "/import/#{id}/0", last_response.headers["location"]
assert_equal [jane.id], store.group(school).members
get "/import/#{id}/0"
assert_includes last_response.body, %(href="/groups/#{school}" class="tag">)
end
end
# A refused save renders the screen the dialog was opened from, the
# notice landing on the row rather than navigating to the contact's
# own page.
def test_a_refused_groups_save_from_the_walk_lands_on_the_row
with_contacts({}) do |store|
store.create_group(name: "Clarks")
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
jane = store.contacts.fetch(0)
get "/import/#{id}/0"
post "/contacts/#{jane.id}/groups", "named" => ["Clarks"], "land" => "/import/#{id}/0"
assert_equal 200, last_response.status
assert_includes last_response.body, "Another group is already named Clarks; nothing was saved."
assert_includes last_response.body, %(<div class="walk">)
end
end
# A Save pressed twice is not a second contact: the first makes
# the contact, and the second is that row's editor's Save — the
# amend the contact's own page would write — met by the etag guard
# the editor always carries, the staged card the re-posted form
# came from hashing differently than the contact now in the book.
def test_a_save_pressed_twice_writes_one_contact
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
get "/import/#{id}/0"
sent = etag
2.times do
post "/import/#{id}/0",
"company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => ""
end
assert_equal 1, store.contacts.length
assert_equal 200, last_response.status
assert_includes last_response.body, "This contact changed since the page loaded; nothing was saved."
end
end
# Saving the last row ends the walk: there is nothing left to come
# back to, so the import closes itself and leaves the walk for the
# group it filed everything under, which is the list of what came
# in and is still there tomorrow.
def test_saving_the_last_row_ends_the_import
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
save(id, 1, first: "Sam", last: "Booles")
assert_equal 2, store.contacts.length
filed = store.all_groups.find { it.name.to_s.start_with?("import-") }
assert_equal "/groups/#{filed.id}", last_response.headers["location"]
assert_equal store.contacts.map(&:id).sort, filed.members.sort
jane = store.contacts.find { it.name == "Jane Booles" }
assert_includes jane.vcard.to_s, "TEL;type=CELL:+1 555 0100"
refute_includes jane.vcard.to_s, "X-SOCIALPROFILE"
refute_includes jane.vcard.to_s, "X-ABRELATEDNAMES"
follow_redirect!
assert_includes last_response.body, %(href="/contacts/#{jane.id}")
# And the walk is gone with it: no screen at all answers the id.
get "/import/#{id}"
assert_equal 404, last_response.status
end
end
# Every card saved out of the group the import named leaves no group
# to land on, so the walk ends on the book itself.
def test_a_walk_that_filed_nothing_ends_on_the_contacts
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles", "named" => [], "groups" => [])
assert_equal "/contacts", last_response.headers["location"]
assert_equal 1, store.contacts.length
assert_empty store.all_groups.map(&:name).grep(/\Aimport-/)
end
end
# The editor's own guard, over the staged card: two tabs on one
# import, and the second save would revert the first.
def test_a_save_against_a_stale_card_is_refused
with_contacts({}) do |store|
id = upload(JANE)
get "/import/#{id}/0"
post "/import/#{id}/0",
"company" => "0", "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "lost"
assert_includes last_response.body, "This card changed since the page loaded"
refute_includes last_response.body, "lost"
assert_empty store.contacts
end
end
# The birthday is the one field the editor holds in the model
# rather than in the card, and a staged contact has no model: the
# save writes it back as the BDAY line it comes in as.
def test_a_birthday_typed_on_the_import_comes_in_with_the_contact
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles",
"birthday" => {"year" => "1985", "month" => "4", "day" => "12"})
assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), store.contacts.fetch(0).birthday
end
end
# And the shape no card can spell is refused rather than dropped:
# the model that holds a partial birthday does not exist until the
# contact is stored (docs/plans/2026-08-31-partial-birthdays.md).
def test_a_birthday_no_card_can_spell_is_refused_until_the_contact_is_stored
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles",
"birthday" => {"year" => "1985", "month" => "", "day" => ""})
assert_equal 200, last_response.status
assert_includes last_response.body, "A card cannot hold a birthday that partial."
assert_empty store.contacts
end
end
# Only a card's one BDAY moves into the model, Store#put's split: a
# card carrying two keeps both in the card, the second one a spelling
# the model does not read, where taking the first would drop the
# other on the Save with nothing to say it went.
def test_a_card_with_two_birthdays_keeps_both
card = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Booles;Jane;;;\r\nFN:Jane Booles\r\n" \
"BDAY:1980-01-02\r\nBDAY:sometime in May\r\nUID:ABC-123\r\nEND:VCARD\r\n"
with_contacts({}) do |store|
id = upload(card)
save(id, 0, first: "Jane", last: "Booles")
stored = store.contacts.fetch(0)
assert_nil stored.birthday
assert_equal ["1980-01-02", "sometime in May"], stored.stored.properties.select { it.name == "BDAY" }.map(&:value)
end
end
# The walk's record of what it saved, read back in a shape it was
# never written in: a broken assumption to raise on, not "nothing
# saved", which would send every row through a second Save.
def test_a_saved_slot_that_is_not_an_object_raises
with_contacts({}) do
id = upload(JANE)
(Pathname.new(ENV.fetch("PRO_TACTS_DATA_DIR")) / ProTacts::Import::Staged::DIRECTORY / id / "saved.json").binwrite("[]")
assert_raises(ArgumentError) { get "/import/#{id}/0" }
end
end
# The group for the import is named when the file is uploaded;
# which of this book's own groups a contact joins is asked beside
# that contact's card, and written by the same Save as its fields.
def test_a_contact_is_put_in_its_groups_beside_its_own_card
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE + PLAIN)
get "/import/#{id}/0"
assert_includes last_response.body, %(<input type="checkbox" name="groups[]" value="#{school}">school)
save(id, 0, first: "Jane", last: "Booles", "groups" => [school])
assert_equal 303, last_response.status
assert_equal [store.contacts.fetch(0).id], store.group(school).members
end
end
# A group that does not exist yet is named beside the contact and
# made with it: a group created before its card is saved is one
# left behind if that card never is.
def test_a_group_named_beside_a_card_is_made_with_it
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles", "named" => [" Clarks "])
clarks = store.all_groups.find { it.name == "Clarks" }
assert_equal [store.contacts.fetch(0).id], clarks.members
end
end
# The group for the import is one of the boxes beside the card, not
# a field on a screen of its own: it is named when the upload
# happens, ticked for every card, and shown rather than applied
# behind the walk's back.
def test_the_group_for_the_import_is_ticked_beside_every_card
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
lot = import_group(id)
assert_match(/\Aimport-\d{8}T\d{6}Z\z/, lot)
# On the first card it is a name, nothing having made it yet.
get "/import/#{id}/0"
assert_includes last_response.body, %(<input type="checkbox" name="named[]" value="#{lot}" checked>)
save(id, 0, first: "Jane", last: "Booles")
made = store.all_groups.find { it.name == lot }
assert_equal [store.contacts.fetch(0).id], made.members
# On the next one it is an ordinary box, ticked.
get "/import/#{id}/1"
assert_includes last_response.body,
%(<input type="checkbox" name="groups[]" value="#{made.id}" checked>)
end
end
# And it can be unticked: an arrival that does not belong with the
# lot is a thing the walk is for deciding.
def test_an_arrival_can_be_kept_out_of_the_import_group
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles", "named" => [])
assert_equal [ProTacts::Group::EVERYONE], store.all_groups.map(&:name)
end
end
# Renaming it is not offered. A name typed before anything has been
# looked at is a name for nothing, and one typed after would leave
# what is already in the book under the old name.
def test_the_group_for_the_import_cannot_be_renamed
with_contacts({}) do |_store|
id = upload(JANE)
get "/import/#{id}/0"
refute_includes last_response.body, %(name="group")
post "/import/#{id}/group", "group" => "somewhere else"
assert_equal 404, last_response.status
end
end
# Everyone's book stands with the rest, ticked: a card comes in
# going out to every phone this book syncs, and the question is
# asked where every other group question is.
def test_everyones_book_is_shown_beside_a_card_and_ticked
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
everyone = store.all_groups.find { it.name == ProTacts::Group::EVERYONE }
get "/import/#{id}/1"
assert_includes last_response.body,
%(<input type="checkbox" name="groups[]" value="#{everyone.id}" checked>)
save(id, 1, first: "Sam", last: "Booles")
assert_equal store.contacts.map(&:id).sort, store.group(everyone.id).members.sort
end
end
# And it can be unticked, like the rest: a card that is to sit on
# the server without going out to anybody's phone.
def test_an_arrival_can_be_kept_out_of_everyones_book
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
everyone = store.all_groups.find { it.name == ProTacts::Group::EVERYONE }
save(id, 1, first: "Sam", last: "Booles", "groups" => [], "named" => [])
sam = store.contacts.find { it.name == "Sam Booles" }
assert sam
refute_includes store.group(everyone.id).members, sam.id
end
end
# A save sent back to be fixed comes back with its boxes as they
# were ticked: nothing is staged between screens, so the form is
# the only record of them until the write.
def test_a_refused_save_keeps_the_groups_it_was_sent_with
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE)
get "/import/#{id}/0"
post "/import/#{id}/0",
"company" => "0", "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [school], "named" => ["Clarks"]
assert_includes last_response.body,
%(<input type="checkbox" name="groups[]" value="#{school}" checked>school)
assert_includes last_response.body,
%(<input type="checkbox" name="named[]" value="Clarks" checked>)
assert_includes last_response.body, %(data-label="clarks")
# And nothing was made for a card that is not in the book.
refute_includes store.all_groups.map(&:name), "Clarks"
end
end
# A book with three hundred groups is the same screen as a book
# with three, and the walk opens it once per contact: the boxes are
# filtered, and the filter is the groups dialog's own
# (Admin::GroupFilter). Every row it is meant to see says which
# name it matches on, and the filter itself is the way to name a
# group this book does not have — one place rather than a list and
# a text field under it.
def test_the_groups_beside_a_card_are_filtered_like_the_dialogs
with_contacts({}) do |store|
store.create_group(name: "school")
id = upload(JANE)
get "/import/#{id}/0"
assert_includes last_response.body, %(<input type="search" placeholder="Filter or add groups")
assert_includes last_response.body, %(data-label="school")
# The offer's tick commits the name into a standing row rather
# than leaving it on the filter, where clearing would lose it.
assert_includes last_response.body,
%(<input type="checkbox" @change="if ($event.target.checked) named.push(filter.trim())">)
assert_includes last_response.body,
%(<input type="checkbox" name="named[]" :value="name" checked @change="named = named.filter(n => n !== name)">)
# A committed name is a ticked row, and a ticked row answers
# neither the filter nor the cap: a decision the form still
# submits is never hidden.
assert_includes last_response.body, %(<template x-for="name in named"><label>)
assert_includes last_response.body, %(row.querySelector('input[type=checkbox]')?.checked)
end
end
# The cap the dialog got (docs/plans/2026-09-22-a-few-groups-at-a-time.md),
# over the picker the walk opens once per contact: eight rows stand
# shown, the rest are marked for the filter to reach, and the button
# counts the whole list. A group already ticked for this contact
# takes a row from the cap rather than standing outside it, and so
# does the group for the import, which is ticked and not yet in the
# list the cap reads.
def test_the_groups_beside_a_card_show_a_few_at_a_time
with_contacts({}) do |store|
("a".."h").each { store.create_group(name: "Group #{it}") }
zulus = store.create_group(name: "Zulus")
id = upload(JANE)
get "/import/#{id}/0"
# All empty, so alphabetical, and the import's own name takes
# the eighth row, so Group h and Zulus are the two that go under.
assert_includes last_response.body, %(<label data-label="zulus" data-capped :hidden="!visible($el)">)
assert_equal 2, last_response.body.scan("data-capped").length
assert_includes last_response.body, "show all 9 groups"
# Ticked on a save that came back to be fixed — and the import's
# own name unticked with it — so Zulus is spared and Group h
# goes under alone.
post "/import/#{id}/0",
"company" => "0", "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [zulus], "named" => []
assert_includes last_response.body, %(<label data-label="zulus" :hidden="!visible($el)">)
assert_includes last_response.body, %(<label data-label="group h" data-capped :hidden="!visible($el)">)
assert_equal 1, last_response.body.scan("data-capped").length
end
end
# The groups most of the book is in are the ones an arrival is
# likeliest to join, so they lead the list and stand above the cap;
# a tie is alphabetical (Admin::ImportGroups).
def test_the_groups_beside_a_card_are_largest_first
with_contacts({"jane" => JANE, "sam" => PLAIN}) do |store|
alpha = store.create_group(name: "alpha")
charlie = store.create_group(name: "charlie")
beta = store.create_group(name: "beta")
zulus = store.create_group(name: "zulus")
store.add_member(zulus, "jane")
store.add_member(zulus, "sam")
store.add_member(charlie, "sam")
store.add_member(beta, "jane")
id = upload(JANE)
get "/import/#{id}/0"
assert_equal [zulus, beta, charlie, alpha],
last_response.body.scan(/name="groups\[\]" value="([^"]+)"/).flatten
end
end
# However few it holds: the listings' order outranks the size
# sort here.
def test_the_groups_beside_a_card_lead_with_the_sync_groups
with_contacts({"jane" => JANE, "sam" => PLAIN}) do |store|
big = store.create_group(name: "big")
store.add_member(big, "jane")
store.add_member(big, "sam")
small_book = store.create_group(name: "sync:alpha")
store.add_member(small_book, "jane")
id = upload(JANE)
get "/import/#{id}/0"
assert_equal [small_book, big],
last_response.body.scan(/name="groups\[\]" value="([^"]+)"/).flatten
end
end
# However many it holds: a nameless group sinks here as it does in
# the listings.
def test_the_groups_beside_a_card_end_with_the_nameless_groups
with_contacts({"jane" => JANE, "sam" => PLAIN}) do |store|
nameless = store.create_group
store.add_member(nameless, "jane")
store.add_member(nameless, "sam")
small = store.create_group(name: "small")
id = upload(JANE)
get "/import/#{id}/0"
assert_equal [small, nameless],
last_response.body.scan(/name="groups\[\]" value="([^"]+)"/).flatten
end
end
def test_the_groups_beside_a_card_offer_no_such_thing_uncapped
with_contacts({}) do |store|
# Seven, the import's own name being the eighth row.
("a".."g").each { store.create_group(name: "Group #{it}") }
id = upload(JANE)
get "/import/#{id}/0"
refute_includes last_response.body, "show all"
refute_includes last_response.body, "data-capped"
end
end
# Contact by contact, so the cards do not all come in alike. The id
# that names no group is dropped rather than carried into
# Store#add_member, which reads a group with `sole` and would
# answer bad input with a 500.
def test_each_contact_comes_in_with_its_own_groups
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE + PLAIN)
lot = import_group(id)
save(id, 0, first: "Jane", last: "Booles", "groups" => [school, "zzzz"], "named" => [lot])
save(id, 1, first: "Sam", last: "Booles")
assert_equal 303, last_response.status
jane = store.contacts.find { it.name == "Jane Booles" }
sam = store.contacts.find { it.name == "Sam Booles" }
assert_equal [jane.id], store.group(school).members
assert_equal [lot, "school", ProTacts::Group::EVERYONE],
store.all_groups.select { it.members.include?(jane.id) }.map(&:name).sort
assert_equal [lot, ProTacts::Group::EVERYONE],
store.all_groups.select { it.members.include?(sam.id) }.map(&:name).sort
end
end
# A group deleted between the tick and the Save is nothing, not a
# 500: the ids are filtered again where they are used.
def test_a_group_that_went_away_before_the_save_is_dropped
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE)
get "/import/#{id}/0"
sent = etag
store.delete_group(school)
post "/import/#{id}/0",
"company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [school]
# The last row there was, so the walk ends: nothing was filed
# under a group of its own, and the book is where it went.
assert_equal "/contacts", last_response.headers["location"]
assert_equal [ProTacts::Group::EVERYONE], store.all_groups.map(&:name)
end
end
# A card that looks like someone already in the book offers to
# update them instead, at the top of its editor, and that offer is
# the default: a row opened with no choice made folds into the best
# match, and new contact is the choice made against it
# (docs/plans/2026-09-23-update-is-the-default.md).
def test_a_row_opens_folded_into_the_contact_it_looks_like_most
with_contacts({"jane" => STORED_JANE}) do |_store|
id = upload(JANE)
get "/import/#{id}/0"
assert_includes last_response.body, %(<div role="tablist" aria-label="Import as" data-variant="segmented">)
assert_includes last_response.body,
%(<a href="/import/#{id}/0?into=" role="tab" aria-selected="false">new contact</a>)
assert_includes last_response.body,
%(<a href="/import/#{id}/0?into=jane" role="tab" aria-selected="true">update Jane Booles</a>)
assert_includes last_response.body, %(<input type="hidden" name="into" value="jane">)
assert_includes last_response.body, %(>Update Jane Booles</button>)
end
end
# Each step of the walk that grows with the file is a span on its
# request's transaction, and none carries a card's content (the
# import branch's comment). The upload and the Save answer 303s,
# traced only under config.ru's statuses.
def test_the_walk_times_its_steps_without_the_cards
setup_sentry_tracing
with_contacts({"jane" => STORED_JANE}) do |_store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
transactions = sentry_events.grep(Sentry::TransactionEvent)
ops = transactions.flat_map { |transaction| transaction.spans.map { it[:op] } }.grep(/\Aimport\./).uniq.sort
assert_equal %w[contacts match merge pare render restage rows split stage write].map { "import.#{it}" }, ops
transactions.each { refute_match(/Booles|Jane/, JSON.generate(it.to_h)) }
end
ensure
teardown_sentry_test
end
# New contact stays a choice, asked for with an empty `into` where
# the update sides name a contact: the editor over the arriving
# card alone.
def test_a_new_contact_is_chosen_against_the_default
with_contacts({"jane" => STORED_JANE}) do |_store|
id = upload(JANE)
get "/import/#{id}/0?into="
assert_includes last_response.body,
%(<a href="/import/#{id}/0?into=" role="tab" aria-selected="true">new contact</a>)
assert_includes last_response.body,
%(<a href="/import/#{id}/0?into=jane" role="tab" aria-selected="false">update Jane Booles</a>)
refute_includes last_response.body, %(name="into")
assert_includes last_response.body, %(>Import</button>)
end
end
# A toggle with one side is not a choice.
def test_a_card_like_nobody_in_the_book_offers_nothing_to_update
with_contacts({"theo" => STORED_JANE.sub("Booles;Jane", "Marsh;Theo").sub("FN:Jane Booles", "FN:Theo Marsh")
.sub("jane@example.com", "theo@elsewhere.org")}) do |_store|
id = upload(JANE)
get "/import/#{id}/0"
refute_includes last_response.body, %(role="tablist")
end
end
# The update is the contact's own editor with the card folded in:
# the contact's name and email, the card's phone beside them, and
# the card's lines that the contact's own stood in for struck on
# the card as exported.
def test_updating_opens_the_contact_with_the_card_folded_in
with_contacts({"jane" => STORED_JANE}) do |_store|
id = upload(JANE.sub("FN:Jane Booles", "FN:Jane B. Booles"))
get "/import/#{id}/0?into=jane"
assert_includes last_response.body,
%(<a href="/import/#{id}/0?into=jane" role="tab" aria-selected="true">update Jane Booles</a>)
assert_includes last_response.body, %(<input type="hidden" name="into" value="jane">)
assert_includes last_response.body, %(value="jane@example.com")
assert_includes last_response.body, %(value="+1 555 0100")
assert_includes last_response.body, %(<li data-dropped><span>FN:Jane B. Booles</span>)
assert_includes last_response.body, %(>Update Jane Booles</button>)
end
end
# Its Save edits that contact rather than adding one: the row is
# in, it leads to the contact it was folded into, and the contact
# joins the group the import files under beside the groups it was
# already in.
def test_saving_an_update_changes_the_contact_rather_than_adding_one
with_contacts({"jane" => STORED_JANE}) do |store|
school = store.create_group(name: "school")
store.add_member(school, "jane")
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles", into: "jane")
assert_equal 303, last_response.status
assert_equal "/import/#{id}/1", last_response.headers["location"]
assert_equal ["jane"], store.contacts.map(&:id)
jane = store.contact("jane")
assert_equal ["+1 555 0100"], jane.phones.map(&:value)
assert_equal ["jane@example.com"], jane.emails.map(&:value)
assert_equal [import_group(id), "school"], store.groups_of("jane").map(&:name).sort
get "/import/#{id}/0"
assert_includes last_response.body, %(<dl class="detail-grid">)
assert_includes last_response.body, %(<a href="/import/#{id}/0" aria-current="page" data-state="saved">)
end
end
# The fold is derived again at the Save, so a contact edited since
# the page loaded refuses it the way any stale editor does.
def test_an_update_over_a_contact_changed_since_is_refused
with_contacts({"jane" => STORED_JANE}) do |store|
id = upload(JANE)
get "/import/#{id}/0?into=jane"
sent = etag
store.put("jane", ProTacts::VCard.new(STORED_JANE.sub("END:VCARD", "NICKNAME:Jay\r\nEND:VCARD")))
post "/import/#{id}/0",
"company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "into" => "jane"
assert_includes last_response.body, "This card changed since the page loaded; nothing was saved."
assert_includes last_response.body, %(<input type="hidden" name="into" value="jane">)
assert_empty store.contact("jane").phones
end
end
# And one deleted since is not quietly a new contact instead.
def test_an_update_whose_contact_is_gone_is_refused
with_contacts({"jane" => STORED_JANE}) do |store|
id = upload(JANE)
get "/import/#{id}/0?into=jane"
sent = etag
store.delete("jane")
post "/import/#{id}/0",
"company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "into" => "jane"
assert_includes last_response.body, "The contact this card was updating is gone; nothing was saved."
assert_empty store.contacts
end
end
# Swept out from under a screen left open, or come back to after
# its last row was saved: the import is gone and only the person
# has another copy.
def test_a_walk_that_is_gone_says_so
with_contacts({}) do |store|
id = upload(JANE)
save(id, 0, first: "Jane", last: "Booles")
get "/import/#{id}/0"
assert_includes last_response.body, "That import is no longer here."
assert_equal 1, store.contacts.length
end
end
# An id off a link is a path if nothing checks it, so only the
# shape this server mints reaches the disk at all.
def test_an_import_id_this_server_never_minted_reads_nothing
with_contacts({}) do |store|
refute ProTacts::ChangeId.minted?("notminted")
get "/import/notminted/0"
assert_includes last_response.body, "That import is no longer here."
assert_empty store.changes
end
end
def test_an_upload_with_no_file_says_what_to_choose
with_contacts({}) do |store|
post "/import"
assert_includes last_response.body, "Choose a .vcf or Monica export to import."
assert_empty store.changes
end
end
def test_a_file_that_is_not_a_set_of_vcards_stages_nothing
with_contacts({}) do |store|
upload("hello\r\n")
assert_equal 200, last_response.status
assert_includes last_response.body, "a line outside a card"
assert_empty store.changes
end
end
# Web#write_card's rule over the one other body this app reads:
# bytes that are not the text they claim to be are refused where
# they are read, rather than at the insert that would 500 on them.
def test_a_file_that_is_not_utf_8_stages_nothing
with_contacts({}) do |store|
upload(JANE.b.sub("Jane".b, "Jan\xFF".b))
assert_includes last_response.body, "contacts.vcf is not UTF-8 text."
assert_empty store.changes
end
end
# A NUL is not text either (VCard.text?), and would leave nothing the
# store could write.
def test_a_file_holding_a_nul_stages_nothing
with_contacts({}) do |store|
upload(JANE.sub("Jane", "Ja\0ne"))
assert_includes last_response.body, "contacts.vcf is not UTF-8 text."
assert_empty store.changes
end
end
# A Monica export is read as the cards it composes, and is a walk like
# any other from there (Import::Monica).
def test_a_monica_export_opens_the_walk_on_its_contacts
export = JSON.generate({
"account" => {"data" => [{"count" => 1, "type" => "contact", "values" => [
{"uuid" => "u", "properties" => {"first_name" => "Jane", "last_name" => "Booles", "job" => "Welder"},
"data" => [{"count" => 1, "type" => "note", "values" => [{"properties" => {"body" => "Likes jigsaws."}}]}]},
]}]},
})
with_contacts({}) do |store|
id = upload(export, name: "monica.json")
follow_redirect!
assert_equal 200, last_response.status
assert_includes last_response.body, "Jane Booles"
assert_includes last_response.body, "Likes jigsaws."
assert_includes last_response.body, "TITLE:Welder"
# Everything from Monica is ticked into the one group, beside
# the import's own.
assert_includes last_response.body, %(<input type="checkbox" name="named[]" value="monica" checked>)
save(id, 0, first: "Jane", last: "Booles", "note" => "Likes jigsaws.")
jane = store.contacts.fetch(0)
assert_equal ["Likes jigsaws."], jane.notes.map(&:value)
assert_equal [jane.id], store.all_groups.find { it.name == "monica" }.members
end
end
# A .vcf is not Monica's, whatever its file is called.
def test_a_vcf_is_not_ticked_into_the_monica_group
with_contacts({}) do
upload(PLAIN, name: "monica.vcf")
follow_redirect!
refute_includes last_response.body, %(value="monica")
end
end
# A card's tags are ticked beside it as groups: the book's own where
# it has one of that name, and a name to make where it does not.
def test_a_cards_tags_are_ticked_as_its_groups
tagged = PLAIN.sub("UID:", "CATEGORIES:school,book club\r\nUID:")
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(tagged)
follow_redirect!
assert_includes last_response.body, %(<input type="checkbox" name="groups[]" value="#{school}" checked>)
assert_includes last_response.body, %(<input type="checkbox" name="named[]" value="book club" checked>)
save(id, 0, first: "Sam", last: "Booles")
sam = store.contacts.fetch(0).id
assert_equal [sam], store.group(school).members
assert_equal [sam], store.all_groups.find { it.name == "book club" }.members
end
end
# Which phones a card goes out to is not a tag's to decide.
def test_a_tag_spelling_a_sync_group_ticks_nothing
tagged = PLAIN.sub("UID:", "CATEGORIES:sync:*\r\nUID:")
with_contacts({}) do |_store|
upload(tagged)
follow_redirect!
refute_includes last_response.body, %(name="named[]" value="sync:*")
end
end
end