tests/input_tests.rs 28.8 K raw
1
//! State-machine tests for the TUI: synthetic key events drive `input::handle_key`
2
//! directly (no terminal needed).
3
4
use std::path::PathBuf;
5
6
use cielago::app::{App, EditTarget, EditorTab, Focus, Mode, Popup, SidebarRow};
7
use cielago::input::handle_key;
8
use cielago::model::{Collection, KeyValueRow, LabelMode, Method, SavedRequest};
9
use cielago::store::AppConfig;
10
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11
12
fn key(code: KeyCode) -> KeyEvent {
13
    KeyEvent::new(code, KeyModifiers::NONE)
14
}
15
16
fn char_key(c: char) -> KeyEvent {
17
    key(KeyCode::Char(c))
18
}
19
20
fn type_str(app: &mut App, s: &str) {
21
    for c in s.chars() {
22
        handle_key(app, char_key(c));
23
    }
24
}
25
26
fn test_collection() -> Collection {
27
    let mut c = Collection::new("test");
28
    c.servers = vec![
29
        "https://one.example.com".into(),
30
        "https://two.example.com".into(),
31
    ];
32
33
    let mut list = SavedRequest::blank("listPets");
34
    list.method = Method::Get;
35
    list.path = "/pets".into();
36
    list.tags = vec!["pets".into()];
37
    list.query.push(KeyValueRow::new("limit", "20", false));
38
    list.query.push(KeyValueRow::new("filter", "", false));
39
40
    let mut create = SavedRequest::blank("createPet");
41
    create.method = Method::Post;
42
    create.path = "/pets".into();
43
    create.tags = vec!["pets".into()];
44
    create.body = Some("{\n  \"name\": \"Fido\"\n}".into());
45
46
    let mut order = SavedRequest::blank("placeOrder");
47
    order.method = Method::Post;
48
    order.path = "/orders".into();
49
    order.tags = vec!["store".into()];
50
51
    c.requests = vec![list, create, order];
52
    c
53
}
54
55
fn test_app() -> App {
56
    App::new(
57
        test_collection(),
58
        PathBuf::from("/tmp/cielago-test.json"),
59
        AppConfig::default(),
60
    )
61
}
62
63
#[test]
64
fn startup_state() {
65
    let app = test_app();
66
    assert_eq!(app.mode, Mode::Normal);
67
    // sidebar: group header + 2 pets requests + group + 1 store request
68
    assert_eq!(app.sidebar_rows.len(), 5);
69
    // first request auto-selected, but focus starts on the sidebar
70
    assert_eq!(app.selected, Some(0));
71
    assert_eq!(app.focus, Focus::Sidebar);
72
}
73
74
#[test]
75
fn startup_restores_the_last_open_request() {
76
    let mut c = test_collection();
77
    c.last_request = Some(c.requests[2].id);
78
    let app = App::new(
79
        c,
80
        PathBuf::from("/tmp/cielago-test.json"),
81
        AppConfig::default(),
82
    );
83
    assert_eq!(app.selected, Some(2));
84
    // the sidebar cursor lands on it too, not back at the top
85
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2));
86
    assert_eq!(app.focus, Focus::Sidebar);
87
}
88
89
#[test]
90
fn startup_expands_the_restored_request_group() {
91
    let mut c = test_collection();
92
    c.groups_collapsed = true;
93
    c.last_request = Some(c.requests[2].id);
94
    let app = App::new(
95
        c,
96
        PathBuf::from("/tmp/cielago-test.json"),
97
        AppConfig::default(),
98
    );
99
    // "store" is expanded so the restored row is visible; "pets" stays collapsed
100
    assert_eq!(app.selected, Some(2));
101
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2));
102
    assert_eq!(app.sidebar_rows.len(), 3);
103
}
104
105
#[test]
106
fn startup_restores_the_focused_pane_and_tab() {
107
    let mut c = test_collection();
108
    c.last_request = Some(c.requests[1].id);
109
    c.last_focus = Some(Focus::Editor);
110
    c.last_tab = Some(EditorTab::Body);
111
    let app = App::new(
112
        c,
113
        PathBuf::from("/tmp/cielago-test.json"),
114
        AppConfig::default(),
115
    );
116
    assert_eq!(app.selected, Some(1));
117
    assert_eq!(app.focus, Focus::Editor);
118
    assert_eq!(app.tab, EditorTab::Body);
119
}
120
121
#[test]
122
fn startup_skips_a_saved_response_pane_with_no_response() {
123
    let mut c = test_collection();
124
    c.last_focus = Some(Focus::Response);
125
    c.last_tab = Some(EditorTab::Docs);
126
    let app = App::new(
127
        c,
128
        PathBuf::from("/tmp/cielago-test.json"),
129
        AppConfig::default(),
130
    );
131
    // responses aren't persisted, so pane 3 would be empty
132
    assert_eq!(app.focus, Focus::Editor);
133
    assert_eq!(app.tab, EditorTab::Docs);
134
}
135
136
// `record_view` rather than `save`: saving writes into the real
137
// `~/.config/cielago/collections`, which a test has no business touching.
138
#[test]
139
fn record_view_captures_request_pane_and_tab() {
140
    let mut app = test_app();
141
    handle_key(&mut app, char_key('j')); // onto the first request row
142
    handle_key(&mut app, char_key('j')); // onto the second
143
    handle_key(&mut app, key(KeyCode::Enter)); // open it
144
    handle_key(&mut app, char_key(']')); // Params -> Headers
145
    handle_key(&mut app, char_key(']')); // Headers -> Body
146
    handle_key(&mut app, char_key('3')); // response pane
147
    app.record_view();
148
149
    let id = app.collection.requests[1].id;
150
    assert_eq!(app.collection.last_request, Some(id));
151
    assert_eq!(app.collection.last_focus, Some(Focus::Response));
152
    assert_eq!(app.collection.last_tab, Some(EditorTab::Body));
153
}
154
155
#[test]
156
fn startup_ignores_a_stale_last_request() {
157
    let mut c = test_collection();
158
    c.last_request = Some(uuid::Uuid::new_v4());
159
    let app = App::new(
160
        c,
161
        PathBuf::from("/tmp/cielago-test.json"),
162
        AppConfig::default(),
163
    );
164
    // request is gone (re-imported spec, deleted operation): fall back to first
165
    assert_eq!(app.selected, Some(0));
166
}
167
168
#[test]
169
fn help_popup_opens_and_closes() {
170
    let mut app = test_app();
171
    handle_key(&mut app, char_key('?'));
172
    assert_eq!(app.popup, Popup::Help);
173
    handle_key(&mut app, key(KeyCode::Esc));
174
    assert_eq!(app.popup, Popup::None);
175
}
176
177
#[test]
178
fn z_toggles_pane_zoom_without_touching_focus() {
179
    let mut app = test_app();
180
    handle_key(&mut app, char_key('2'));
181
    handle_key(&mut app, char_key('z'));
182
    assert!(app.zoom);
183
    assert_eq!(app.focus, Focus::Editor);
184
    // Focus still moves while zoomed — it just picks the maximized pane.
185
    handle_key(&mut app, key(KeyCode::Tab));
186
    assert!(app.zoom);
187
    assert_eq!(app.focus, Focus::Response);
188
    handle_key(&mut app, char_key('z'));
189
    assert!(!app.zoom);
190
}
191
192
#[test]
193
fn sidebar_navigation_and_selection() {
194
    let mut app = test_app();
195
    handle_key(&mut app, char_key('1'));
196
    assert_eq!(app.focus, Focus::Sidebar);
197
    handle_key(&mut app, char_key('j'));
198
    assert_eq!(app.sidebar_sel, 1);
199
    handle_key(&mut app, char_key('j'));
200
    handle_key(&mut app, key(KeyCode::Enter));
201
    assert_eq!(app.selected, Some(1)); // createPet
202
    assert_eq!(app.focus, Focus::Editor);
203
    // body loaded into textarea
204
    assert!(app.textarea.lines().join("\n").contains("Fido"));
205
}
206
207
#[test]
208
fn sidebar_group_collapse() {
209
    let mut app = test_app();
210
    handle_key(&mut app, char_key('1'));
211
    // row 0 is the "pets" group
212
    handle_key(&mut app, key(KeyCode::Enter));
213
    assert!(app.collapsed.contains("pets"));
214
    assert_eq!(app.sidebar_rows.len(), 3); // pets group collapsed
215
    handle_key(&mut app, key(KeyCode::Enter));
216
    assert!(!app.collapsed.contains("pets"));
217
    assert_eq!(app.sidebar_rows.len(), 5);
218
}
219
220
#[test]
221
fn quit_guards_unsaved_changes() {
222
    let mut app = test_app();
223
    assert!(!app.dirty);
224
    handle_key(&mut app, char_key('q'));
225
    assert!(app.should_quit);
226
227
    let mut app = test_app();
228
    app.dirty = true;
229
    handle_key(&mut app, char_key('q'));
230
    assert!(!app.should_quit);
231
    assert!(app.status.contains("Unsaved"));
232
    // :q! forces
233
    handle_key(&mut app, char_key(':'));
234
    type_str(&mut app, "q!");
235
    handle_key(&mut app, key(KeyCode::Enter));
236
    assert!(app.should_quit);
237
}
238
239
#[test]
240
fn tab_cycling() {
241
    let mut app = test_app();
242
    assert_eq!(app.tab, EditorTab::Params);
243
    handle_key(&mut app, char_key(']'));
244
    assert_eq!(app.tab, EditorTab::Headers);
245
    handle_key(&mut app, char_key(']'));
246
    assert_eq!(app.tab, EditorTab::Body);
247
    handle_key(&mut app, char_key('['));
248
    assert_eq!(app.tab, EditorTab::Headers);
249
}
250
251
#[test]
252
fn tab_cycling_letter_aliases() {
253
    let mut app = test_app();
254
    assert_eq!(app.tab, EditorTab::Params);
255
    handle_key(&mut app, char_key('L'));
256
    assert_eq!(app.tab, EditorTab::Headers);
257
    handle_key(&mut app, char_key('L'));
258
    assert_eq!(app.tab, EditorTab::Body);
259
    handle_key(&mut app, char_key('H'));
260
    assert_eq!(app.tab, EditorTab::Headers);
261
    // wraps backwards past Params into Variables
262
    handle_key(&mut app, char_key('H'));
263
    handle_key(&mut app, char_key('H'));
264
    assert_eq!(app.tab, EditorTab::Variables);
265
}
266
267
#[test]
268
fn slash_filters_the_sidebar() {
269
    let mut app = test_app();
270
    handle_key(&mut app, char_key('/'));
271
    assert_eq!(app.mode, Mode::Search);
272
    assert_eq!(app.focus, Focus::Sidebar);
273
274
    type_str(&mut app, "order");
275
    // "store" group header + placeOrder only
276
    assert_eq!(app.sidebar_rows.len(), 2);
277
    // cursor parked on the match, not the group header
278
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2));
279
280
    // Enter keeps the filter and returns to Normal.
281
    handle_key(&mut app, key(KeyCode::Enter));
282
    assert_eq!(app.mode, Mode::Normal);
283
    assert_eq!(app.filter, "order");
284
    handle_key(&mut app, key(KeyCode::Enter)); // open the match
285
    assert_eq!(app.selected, Some(2));
286
287
    // Esc in the sidebar clears the filter.
288
    handle_key(&mut app, char_key('1'));
289
    handle_key(&mut app, key(KeyCode::Esc));
290
    assert!(app.filter.is_empty());
291
    assert_eq!(app.sidebar_rows.len(), 5);
292
}
293
294
#[test]
295
fn search_matches_path_method_and_tag() {
296
    let mut app = test_app();
297
298
    handle_key(&mut app, char_key('/'));
299
    type_str(&mut app, "/pets");
300
    assert_eq!(app.sidebar_rows.len(), 3); // pets group + 2 requests
301
    handle_key(&mut app, key(KeyCode::Esc));
302
    assert!(app.filter.is_empty());
303
304
    handle_key(&mut app, char_key('/'));
305
    type_str(&mut app, "post");
306
    assert_eq!(app.sidebar_rows.len(), 4); // createPet + placeOrder, 2 groups
307
    handle_key(&mut app, key(KeyCode::Esc));
308
309
    handle_key(&mut app, char_key('/'));
310
    type_str(&mut app, "store");
311
    assert_eq!(app.sidebar_rows.len(), 2);
312
313
    // backspacing widens the match set again
314
    handle_key(&mut app, key(KeyCode::Backspace));
315
    handle_key(&mut app, key(KeyCode::Backspace));
316
    handle_key(&mut app, key(KeyCode::Backspace));
317
    handle_key(&mut app, key(KeyCode::Backspace));
318
    handle_key(&mut app, key(KeyCode::Backspace));
319
    assert_eq!(app.sidebar_rows.len(), 5);
320
}
321
322
#[test]
323
fn search_shows_matches_inside_collapsed_groups() {
324
    let mut app = test_app();
325
    app.collapsed.insert("pets".into());
326
    app.rebuild_sidebar();
327
    assert_eq!(app.sidebar_rows.len(), 3);
328
329
    handle_key(&mut app, char_key('/'));
330
    type_str(&mut app, "createPet");
331
    assert_eq!(app.sidebar_rows.len(), 2);
332
}
333
334
#[test]
335
fn label_mode_cycles_and_persists_on_the_collection() {
336
    let mut app = test_app();
337
    handle_key(&mut app, char_key('1'));
338
    assert_eq!(app.collection.label_mode, LabelMode::Name);
339
    handle_key(&mut app, char_key('t'));
340
    assert_eq!(app.collection.label_mode, LabelMode::Summary);
341
    handle_key(&mut app, char_key('t'));
342
    assert_eq!(app.collection.label_mode, LabelMode::Path);
343
    handle_key(&mut app, char_key('t'));
344
    assert_eq!(app.collection.label_mode, LabelMode::Name);
345
    assert!(app.dirty);
346
}
347
348
#[test]
349
fn label_command_sets_mode() {
350
    let mut app = test_app();
351
    handle_key(&mut app, char_key(':'));
352
    type_str(&mut app, "label path");
353
    handle_key(&mut app, key(KeyCode::Enter));
354
    assert_eq!(app.collection.label_mode, LabelMode::Path);
355
356
    handle_key(&mut app, char_key(':'));
357
    type_str(&mut app, "label nonsense");
358
    handle_key(&mut app, key(KeyCode::Enter));
359
    assert_eq!(app.collection.label_mode, LabelMode::Path);
360
    assert!(app.status.contains("Usage"));
361
}
362
363
#[test]
364
fn rename_all_rewrites_names_from_paths() {
365
    let mut app = test_app();
366
    handle_key(&mut app, char_key(':'));
367
    type_str(&mut app, "rename-all method-path");
368
    handle_key(&mut app, key(KeyCode::Enter));
369
    assert_eq!(app.collection.requests[0].name, "GET /pets");
370
    assert_eq!(app.collection.requests[1].name, "POST /pets");
371
    assert_eq!(app.collection.requests[2].name, "POST /orders");
372
    assert!(app.dirty);
373
}
374
375
#[test]
376
fn edit_query_value_inline() {
377
    let mut app = test_app();
378
    handle_key(&mut app, char_key('2'));
379
    assert_eq!(app.tab, EditorTab::Params);
380
    // row 0 = limit (value "20")
381
    handle_key(&mut app, char_key('i'));
382
    assert_eq!(app.mode, Mode::Insert);
383
    assert!(matches!(app.editing, Some(EditTarget::Cell { .. })));
384
    handle_key(&mut app, char_key('5'));
385
    handle_key(&mut app, key(KeyCode::Enter));
386
    assert_eq!(app.mode, Mode::Normal);
387
    assert_eq!(app.collection.requests[0].query[0].value, "205");
388
    assert!(app.dirty);
389
}
390
391
#[test]
392
fn space_toggles_row() {
393
    let mut app = test_app();
394
    handle_key(&mut app, char_key('2'));
395
    assert!(!app.collection.requests[0].query[0].enabled);
396
    handle_key(&mut app, char_key(' '));
397
    assert!(app.collection.requests[0].query[0].enabled);
398
}
399
400
#[test]
401
fn add_header_row_with_uuid_variable() {
402
    let mut app = test_app();
403
    handle_key(&mut app, char_key('2'));
404
    handle_key(&mut app, char_key(']')); // Headers tab
405
    assert_eq!(app.tab, EditorTab::Headers);
406
    handle_key(&mut app, char_key('a'));
407
    type_str(&mut app, "X-Request-Id");
408
    handle_key(&mut app, key(KeyCode::Enter)); // chains to value edit
409
    type_str(&mut app, "{{uuid}}");
410
    handle_key(&mut app, key(KeyCode::Enter));
411
    assert_eq!(app.mode, Mode::Normal);
412
    let headers = &app.collection.requests[0].headers;
413
    assert_eq!(headers.len(), 1);
414
    assert_eq!(headers[0].key, "X-Request-Id");
415
    assert_eq!(headers[0].value, "{{uuid}}");
416
    assert!(headers[0].enabled);
417
}
418
419
#[test]
420
fn body_textarea_editing() {
421
    let mut app = test_app();
422
    // select createPet (has a body)
423
    app.select_request(1);
424
    handle_key(&mut app, char_key(']'));
425
    handle_key(&mut app, char_key(']')); // Body tab
426
    assert_eq!(app.tab, EditorTab::Body);
427
    handle_key(&mut app, char_key('i'));
428
    assert_eq!(app.mode, Mode::Insert);
429
    handle_key(&mut app, key(KeyCode::Esc));
430
    assert_eq!(app.mode, Mode::Normal);
431
    assert!(
432
        app.collection.requests[1]
433
            .body
434
            .as_ref()
435
            .unwrap()
436
            .contains("Fido")
437
    );
438
}
439
440
#[test]
441
fn body_tab_scrolls_the_read_only_view() {
442
    let mut app = test_app();
443
    app.select_request(1);
444
    app.set_textarea_text(&(1..=40).map(|i| format!("line {i}\n")).collect::<String>());
445
    app.tab = EditorTab::Body;
446
447
    // The highlighted body view follows the textarea cursor.
448
    handle_key(&mut app, char_key('j'));
449
    handle_key(&mut app, char_key('j'));
450
    assert_eq!(app.textarea.cursor().0, 2);
451
    handle_key(&mut app, char_key('k'));
452
    assert_eq!(app.textarea.cursor().0, 1);
453
    handle_key(&mut app, char_key('d'));
454
    assert_eq!(app.textarea.cursor().0, 16);
455
    handle_key(&mut app, char_key('u'));
456
    assert_eq!(app.textarea.cursor().0, 1);
457
    handle_key(&mut app, char_key('G'));
458
    assert!(app.textarea.cursor().0 >= 39);
459
    handle_key(&mut app, char_key('g'));
460
    assert_eq!(app.textarea.cursor().0, 0);
461
462
    // `d` scrolls here rather than deleting a row, but the other editor keys
463
    // still reach their handlers.
464
    assert_eq!(app.collection.requests[1].method, Method::Post);
465
    handle_key(&mut app, char_key('m'));
466
    assert_eq!(app.collection.requests[1].method, Method::Put);
467
    handle_key(&mut app, char_key('i'));
468
    assert_eq!(app.mode, Mode::Insert);
469
}
470
471
#[test]
472
fn docs_tab_scrolls_and_stays_read_only() {
473
    let mut app = test_app();
474
    app.select_request(0); // moves focus to the editor
475
    app.tab = EditorTab::Docs;
476
    let params_before = app.collection.requests[0].query.len();
477
478
    handle_key(&mut app, char_key('j'));
479
    handle_key(&mut app, char_key('j'));
480
    assert_eq!(app.docs_scroll, 2);
481
    handle_key(&mut app, char_key('k'));
482
    assert_eq!(app.docs_scroll, 1);
483
    handle_key(&mut app, char_key('d'));
484
    assert_eq!(app.docs_scroll, 16);
485
    handle_key(&mut app, char_key('u'));
486
    assert_eq!(app.docs_scroll, 1);
487
    handle_key(&mut app, char_key('g'));
488
    assert_eq!(app.docs_scroll, 0);
489
490
    // `d` scrolled instead of deleting, and `i` doesn't open an editor here.
491
    assert_eq!(app.collection.requests[0].query.len(), params_before);
492
    handle_key(&mut app, char_key('i'));
493
    assert_eq!(app.mode, Mode::Normal);
494
    assert!(!app.dirty);
495
496
    // Opening another request resets the scroll.
497
    app.docs_scroll = 5;
498
    app.select_request(2);
499
    assert_eq!(app.docs_scroll, 0);
500
}
501
502
#[test]
503
fn help_popup_scrolls() {
504
    let mut app = test_app();
505
    handle_key(&mut app, char_key('?'));
506
    assert_eq!(app.help_scroll, 0);
507
    handle_key(&mut app, char_key('j'));
508
    handle_key(&mut app, char_key('j'));
509
    assert_eq!(app.help_scroll, 2);
510
    handle_key(&mut app, char_key('k'));
511
    assert_eq!(app.help_scroll, 1);
512
    handle_key(&mut app, char_key('g'));
513
    assert_eq!(app.help_scroll, 0);
514
    // Reopening starts back at the top.
515
    handle_key(&mut app, char_key('d'));
516
    assert!(app.help_scroll > 0);
517
    handle_key(&mut app, key(KeyCode::Esc));
518
    handle_key(&mut app, char_key('?'));
519
    assert_eq!(app.help_scroll, 0);
520
}
521
522
#[test]
523
fn env_popup_add_and_select_server() {
524
    let mut app = test_app();
525
    handle_key(&mut app, char_key('E'));
526
    assert_eq!(app.popup, Popup::Env);
527
    handle_key(&mut app, char_key('a'));
528
    type_str(&mut app, "http://localhost:8080");
529
    handle_key(&mut app, key(KeyCode::Enter));
530
    assert_eq!(app.collection.servers.len(), 3);
531
    assert_eq!(app.collection.active_server, 2);
532
    handle_key(&mut app, key(KeyCode::Esc));
533
    assert_eq!(app.popup, Popup::None);
534
}
535
536
#[test]
537
fn auth_popup_edits_and_applies() {
538
    use cielago::model::AuthKind;
539
540
    let mut app = test_app();
541
    handle_key(&mut app, char_key('A'));
542
    assert_eq!(app.popup, Popup::Auth);
543
    // Field 0 is the kind toggle, defaulting to bearer for a fresh config.
544
    // Cycle it to oauth2 (bearer -> apikey -> oauth2).
545
    handle_key(&mut app, char_key(' '));
546
    handle_key(&mut app, char_key(' '));
547
    assert_eq!(app.auth_form.kind, AuthKind::Oauth2);
548
    // Now the oauth rows show: 1 = token url, 2 = client id, 5 = style.
549
    handle_key(&mut app, char_key('j'));
550
    handle_key(&mut app, char_key('i'));
551
    type_str(&mut app, "https://auth.example.com/token");
552
    handle_key(&mut app, key(KeyCode::Enter));
553
    handle_key(&mut app, char_key('j'));
554
    handle_key(&mut app, char_key('i'));
555
    type_str(&mut app, "my-client");
556
    handle_key(&mut app, key(KeyCode::Enter));
557
    // style toggle: field 5
558
    for _ in 0..3 {
559
        handle_key(&mut app, char_key('j'));
560
    }
561
    assert_eq!(app.auth_field, 5);
562
    handle_key(&mut app, char_key(' '));
563
    // close + apply
564
    handle_key(&mut app, key(KeyCode::Esc));
565
    assert_eq!(app.popup, Popup::None);
566
    let auth = app.collection.auth.as_ref().unwrap();
567
    assert_eq!(auth.kind, AuthKind::Oauth2);
568
    assert_eq!(auth.token_url, "https://auth.example.com/token");
569
    assert_eq!(auth.client_id, "my-client");
570
    assert_eq!(auth.auth_style, cielago::model::AuthStyle::Post);
571
    assert!(app.dirty);
572
}
573
574
#[test]
575
fn auth_popup_sets_bearer_token() {
576
    use cielago::model::AuthKind;
577
578
    let mut app = test_app();
579
    handle_key(&mut app, char_key('A'));
580
    // Defaults to bearer; field 1 is the token.
581
    assert_eq!(app.auth_form.kind, AuthKind::Bearer);
582
    handle_key(&mut app, char_key('j'));
583
    handle_key(&mut app, char_key('i'));
584
    type_str(&mut app, "sk-live-123");
585
    handle_key(&mut app, key(KeyCode::Enter));
586
    handle_key(&mut app, key(KeyCode::Esc));
587
588
    let auth = app.collection.auth.as_ref().unwrap();
589
    assert_eq!(auth.kind, AuthKind::Bearer);
590
    assert_eq!(auth.token, "sk-live-123");
591
}
592
593
#[test]
594
fn auth_popup_sets_api_key_header() {
595
    use cielago::model::AuthKind;
596
597
    let mut app = test_app();
598
    handle_key(&mut app, char_key('A'));
599
    // bearer -> apikey.
600
    handle_key(&mut app, char_key(' '));
601
    assert_eq!(app.auth_form.kind, AuthKind::ApiKey);
602
    // apikey rows: 1 = header name, 2 = value.
603
    handle_key(&mut app, char_key('j'));
604
    handle_key(&mut app, char_key('i'));
605
    type_str(&mut app, "X-Custom-Key");
606
    handle_key(&mut app, key(KeyCode::Enter));
607
    handle_key(&mut app, char_key('j'));
608
    handle_key(&mut app, char_key('i'));
609
    type_str(&mut app, "abc123");
610
    handle_key(&mut app, key(KeyCode::Enter));
611
    handle_key(&mut app, key(KeyCode::Esc));
612
613
    let auth = app.collection.auth.as_ref().unwrap();
614
    assert_eq!(auth.kind, AuthKind::ApiKey);
615
    assert_eq!(auth.header, "X-Custom-Key");
616
    assert_eq!(auth.token, "abc123");
617
    assert_eq!(auth.api_key_header(), "X-Custom-Key");
618
}
619
620
#[test]
621
fn new_request_flow() {
622
    let mut app = test_app();
623
    handle_key(&mut app, char_key('1'));
624
    handle_key(&mut app, char_key('n'));
625
    type_str(&mut app, "my custom request");
626
    handle_key(&mut app, key(KeyCode::Enter));
627
    assert_eq!(app.collection.requests.len(), 4);
628
    assert_eq!(app.selected, Some(3));
629
    assert_eq!(app.collection.requests[3].name, "my custom request");
630
}
631
632
#[test]
633
fn rename_and_delete_request() {
634
    let mut app = test_app();
635
    handle_key(&mut app, char_key('1'));
636
    handle_key(&mut app, char_key('j')); // first request row
637
    handle_key(&mut app, char_key('r'));
638
    // rename input prefilled with current name; replace
639
    handle_key(&mut app, key(KeyCode::Home));
640
    for _ in 0..20 {
641
        handle_key(&mut app, key(KeyCode::Delete));
642
    }
643
    type_str(&mut app, "renamed");
644
    handle_key(&mut app, key(KeyCode::Enter));
645
    assert_eq!(app.collection.requests[0].name, "renamed");
646
647
    // focus stays in the sidebar on the renamed row; delete it
648
    assert_eq!(app.focus, Focus::Sidebar);
649
    handle_key(&mut app, char_key('d'));
650
    assert_eq!(app.collection.requests.len(), 2);
651
    assert!(!app.collection.requests.iter().any(|r| r.name == "renamed"));
652
}
653
654
#[test]
655
fn variables_tab_roundtrip() {
656
    let mut app = test_app();
657
    handle_key(&mut app, char_key('2'));
658
    handle_key(&mut app, char_key('[')); // Variables (prev of Params)
659
    assert_eq!(app.tab, EditorTab::Variables);
660
    handle_key(&mut app, char_key('a'));
661
    type_str(&mut app, "tenant");
662
    handle_key(&mut app, key(KeyCode::Enter));
663
    type_str(&mut app, "acme");
664
    handle_key(&mut app, key(KeyCode::Enter));
665
    assert_eq!(app.collection.variables.len(), 1);
666
    let map = cielago::model::variables_map(&app.collection.variables);
667
    assert_eq!(map.get("tenant").unwrap(), "acme");
668
}
669
670
// ----- ad-hoc requests and collections -----
671
672
/// Open the URL prompt on the selected request with a cleared buffer.
673
fn start_url_edit(app: &mut App) {
674
    handle_key(app, char_key('2'));
675
    handle_key(app, char_key('p'));
676
    assert_eq!(app.editing, Some(EditTarget::Url));
677
    handle_key(app, key(KeyCode::Home));
678
    for _ in 0..60 {
679
        handle_key(app, key(KeyCode::Delete));
680
    }
681
}
682
683
fn type_url(app: &mut App, url: &str) {
684
    start_url_edit(app);
685
    type_str(app, url);
686
    handle_key(app, key(KeyCode::Enter));
687
}
688
689
#[test]
690
fn edit_url_sets_path_and_syncs_path_params() {
691
    let mut app = test_app();
692
    type_url(&mut app, "/pets/{petId}/photos");
693
694
    let req = &app.collection.requests[0];
695
    assert_eq!(req.path, "/pets/{petId}/photos");
696
    assert_eq!(req.path_params.len(), 1);
697
    assert_eq!(req.path_params[0].key, "petId");
698
    assert!(app.dirty);
699
    // No `?` in the input, so the existing query rows are untouched.
700
    assert_eq!(req.query.len(), 2);
701
702
    // Removing the placeholder prunes the row again.
703
    type_url(&mut app, "/pets");
704
    assert!(app.collection.requests[0].path_params.is_empty());
705
}
706
707
#[test]
708
fn pasting_a_full_url_adds_and_activates_the_server() {
709
    let mut app = test_app();
710
    type_url(
711
        &mut app,
712
        "https://three.example.com/v2/pets?limit=5&sort=name",
713
    );
714
715
    assert_eq!(app.collection.servers.len(), 3);
716
    assert_eq!(app.collection.active_server, 2);
717
    assert_eq!(app.collection.base_url(), Some("https://three.example.com"));
718
    let req = &app.collection.requests[0];
719
    assert_eq!(req.path, "/v2/pets");
720
    let query: Vec<(&str, &str)> = req
721
        .query
722
        .iter()
723
        .map(|r| (r.key.as_str(), r.value.as_str()))
724
        .collect();
725
    assert_eq!(query, vec![("limit", "5"), ("sort", "name")]);
726
    assert!(req.query.iter().all(|r| r.enabled));
727
}
728
729
#[test]
730
fn pasting_a_known_origin_switches_to_it_without_duplicating() {
731
    let mut app = test_app();
732
    assert_eq!(app.collection.active_server, 0);
733
    type_url(&mut app, "https://two.example.com/pets");
734
735
    assert_eq!(app.collection.servers.len(), 2);
736
    assert_eq!(app.collection.active_server, 1);
737
    assert_eq!(app.collection.requests[0].path, "/pets");
738
}
739
740
#[test]
741
fn pasting_without_a_query_keeps_existing_params() {
742
    let mut app = test_app();
743
    type_url(&mut app, "https://one.example.com/pets/all");
744
745
    let req = &app.collection.requests[0];
746
    assert_eq!(req.path, "/pets/all");
747
    assert_eq!(req.query.len(), 2);
748
    // Disabled optional params from the fixture survive untouched.
749
    assert!(req.query.iter().all(|r| !r.enabled));
750
    assert_eq!(req.query[0].key, "limit");
751
}
752
753
#[test]
754
fn non_http_scheme_is_rejected() {
755
    let mut app = test_app();
756
    type_url(&mut app, "ftp://files.example.com/pets");
757
758
    assert_eq!(app.collection.requests[0].path, "/pets");
759
    assert_eq!(app.collection.servers.len(), 2);
760
    assert!(app.status.contains("http(s)"));
761
}
762
763
#[test]
764
fn duplicate_request_clones_directly_after_the_original() {
765
    let mut app = test_app();
766
    handle_key(&mut app, char_key('1'));
767
    handle_key(&mut app, char_key('j')); // first request row (listPets)
768
    handle_key(&mut app, char_key('y'));
769
770
    assert_eq!(app.collection.requests.len(), 4);
771
    assert_eq!(app.collection.requests[1].name, "listPets copy");
772
    assert_ne!(app.collection.requests[0].id, app.collection.requests[1].id);
773
    assert_eq!(app.collection.requests[1].path, "/pets");
774
    assert_eq!(app.collection.requests[1].query.len(), 2);
775
    // The original is still in place, and the clone is what's open.
776
    assert_eq!(app.collection.requests[0].name, "listPets");
777
    assert_eq!(app.selected, Some(1));
778
    assert_eq!(app.focus, Focus::Sidebar);
779
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(1));
780
    assert!(app.dirty);
781
}
782
783
#[test]
784
fn duplicate_twice_numbers_the_copies() {
785
    let mut app = test_app();
786
    handle_key(&mut app, char_key('1'));
787
    handle_key(&mut app, char_key('j'));
788
    handle_key(&mut app, char_key('y'));
789
    handle_key(&mut app, char_key('y'));
790
791
    assert_eq!(app.collection.requests.len(), 5);
792
    let names: Vec<&str> = app
793
        .collection
794
        .requests
795
        .iter()
796
        .map(|r| r.name.as_str())
797
        .collect();
798
    // The cursor followed the first clone, so the second `y` duplicates *it* —
799
    // and the ` copy` suffix is stripped first, giving `copy 2` not `copy copy`.
800
    assert_eq!(names[1], "listPets copy");
801
    assert_eq!(names[2], "listPets copy 2");
802
}
803
804
#[test]
805
fn new_request_chains_into_the_url_prompt() {
806
    let mut app = test_app();
807
    handle_key(&mut app, char_key('1'));
808
    handle_key(&mut app, char_key('n'));
809
    type_str(&mut app, "adhoc");
810
    handle_key(&mut app, key(KeyCode::Enter));
811
812
    // The name commit leaves you in the URL prompt rather than on `GET /`.
813
    assert_eq!(app.editing, Some(EditTarget::Url));
814
    assert_eq!(app.mode, Mode::Insert);
815
    type_str(&mut app, "https://four.example.com/ip");
816
    handle_key(&mut app, key(KeyCode::Enter));
817
818
    let req = app.collection.requests.last().unwrap();
819
    assert_eq!(req.name, "adhoc");
820
    assert_eq!(req.path, "/ip");
821
    assert_eq!(app.collection.base_url(), Some("https://four.example.com"));
822
}
823
824
#[test]
825
fn switch_collection_replaces_state() {
826
    let mut app = test_app();
827
    handle_key(&mut app, char_key('/'));
828
    type_str(&mut app, "orders");
829
    handle_key(&mut app, key(KeyCode::Enter));
830
    handle_key(&mut app, char_key('2'));
831
    handle_key(&mut app, char_key('m')); // dirty it
832
833
    let mut other = Collection::new("other");
834
    other.requests = vec![SavedRequest::blank("only")];
835
    app.switch_collection(other, PathBuf::from("/tmp/cielago-other.json"));
836
837
    assert_eq!(app.collection.name, "other");
838
    assert_eq!(app.collection.requests.len(), 1);
839
    assert_eq!(app.selected, Some(0));
840
    assert!(!app.dirty);
841
    assert!(app.filter.is_empty());
842
    assert!(app.response.is_none());
843
    assert!(app.status.contains("other"));
844
    // Tracked in memory only — `switch_collection` must not write to the real
845
    // `~/.config/cielago`, which is where `store::config_dir` always points.
846
    assert_eq!(app.config.last_collection.as_deref(), Some("other"));
847
}
848
849
#[test]
850
fn new_collection_command_refuses_when_dirty() {
851
    let mut app = test_app();
852
    handle_key(&mut app, char_key('2'));
853
    handle_key(&mut app, char_key('m')); // cycle method → dirty
854
    assert!(app.dirty);
855
856
    // The dirty guard runs before any filesystem access, so this touches nothing.
857
    handle_key(&mut app, char_key(':'));
858
    type_str(&mut app, "new Scratch");
859
    handle_key(&mut app, key(KeyCode::Enter));
860
861
    assert_eq!(app.collection.name, "test");
862
    assert!(app.status.contains("Unsaved changes"));
863
}
864
865
#[test]
866
fn new_collection_command_rejects_a_missing_name() {
867
    let mut app = test_app();
868
    handle_key(&mut app, char_key(':'));
869
    type_str(&mut app, "new  ");
870
    handle_key(&mut app, key(KeyCode::Enter));
871
872
    assert_eq!(app.collection.name, "test");
873
    assert!(app.status.starts_with("Usage: :new"));
874
}