tests/input_tests.rs 28.9 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 the body view
204
    assert!(app.body_text.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_has_no_inline_editor() {
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
    // `i` no longer opens an in-app editor; the body is edited via `$EDITOR`.
428
    handle_key(&mut app, char_key('i'));
429
    assert_eq!(app.mode, Mode::Normal);
430
    // `e` queues an external edit instead.
431
    handle_key(&mut app, char_key('e'));
432
    assert_eq!(app.pending_external, Some(cielago::app::ExternalEdit::Body));
433
    assert!(
434
        app.collection.requests[1]
435
            .body
436
            .as_ref()
437
            .unwrap()
438
            .contains("Fido")
439
    );
440
}
441
442
#[test]
443
fn body_tab_scrolls_the_read_only_view() {
444
    let mut app = test_app();
445
    app.select_request(1);
446
    app.set_body_text(&(1..=40).map(|i| format!("line {i}\n")).collect::<String>());
447
    app.tab = EditorTab::Body;
448
449
    // The read-only body view scrolls with a plain offset.
450
    handle_key(&mut app, char_key('j'));
451
    handle_key(&mut app, char_key('j'));
452
    assert_eq!(app.body_scroll, 2);
453
    handle_key(&mut app, char_key('k'));
454
    assert_eq!(app.body_scroll, 1);
455
    handle_key(&mut app, char_key('d'));
456
    assert_eq!(app.body_scroll, 16);
457
    handle_key(&mut app, char_key('u'));
458
    assert_eq!(app.body_scroll, 1);
459
    handle_key(&mut app, char_key('g'));
460
    assert_eq!(app.body_scroll, 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
    // `i` on the Body tab is a no-op, not an editor.
468
    handle_key(&mut app, char_key('i'));
469
    assert_eq!(app.mode, Mode::Normal);
470
}
471
472
#[test]
473
fn docs_tab_scrolls_and_stays_read_only() {
474
    let mut app = test_app();
475
    app.select_request(0); // moves focus to the editor
476
    app.tab = EditorTab::Docs;
477
    let params_before = app.collection.requests[0].query.len();
478
479
    handle_key(&mut app, char_key('j'));
480
    handle_key(&mut app, char_key('j'));
481
    assert_eq!(app.docs_scroll, 2);
482
    handle_key(&mut app, char_key('k'));
483
    assert_eq!(app.docs_scroll, 1);
484
    handle_key(&mut app, char_key('d'));
485
    assert_eq!(app.docs_scroll, 16);
486
    handle_key(&mut app, char_key('u'));
487
    assert_eq!(app.docs_scroll, 1);
488
    handle_key(&mut app, char_key('g'));
489
    assert_eq!(app.docs_scroll, 0);
490
491
    // `d` scrolled instead of deleting, and `i` doesn't open an editor here.
492
    assert_eq!(app.collection.requests[0].query.len(), params_before);
493
    handle_key(&mut app, char_key('i'));
494
    assert_eq!(app.mode, Mode::Normal);
495
    assert!(!app.dirty);
496
497
    // Opening another request resets the scroll.
498
    app.docs_scroll = 5;
499
    app.select_request(2);
500
    assert_eq!(app.docs_scroll, 0);
501
}
502
503
#[test]
504
fn help_popup_scrolls() {
505
    let mut app = test_app();
506
    handle_key(&mut app, char_key('?'));
507
    assert_eq!(app.help_scroll, 0);
508
    handle_key(&mut app, char_key('j'));
509
    handle_key(&mut app, char_key('j'));
510
    assert_eq!(app.help_scroll, 2);
511
    handle_key(&mut app, char_key('k'));
512
    assert_eq!(app.help_scroll, 1);
513
    handle_key(&mut app, char_key('g'));
514
    assert_eq!(app.help_scroll, 0);
515
    // Reopening starts back at the top.
516
    handle_key(&mut app, char_key('d'));
517
    assert!(app.help_scroll > 0);
518
    handle_key(&mut app, key(KeyCode::Esc));
519
    handle_key(&mut app, char_key('?'));
520
    assert_eq!(app.help_scroll, 0);
521
}
522
523
#[test]
524
fn env_popup_add_and_select_server() {
525
    let mut app = test_app();
526
    handle_key(&mut app, char_key('E'));
527
    assert_eq!(app.popup, Popup::Env);
528
    handle_key(&mut app, char_key('a'));
529
    type_str(&mut app, "http://localhost:8080");
530
    handle_key(&mut app, key(KeyCode::Enter));
531
    assert_eq!(app.collection.servers.len(), 3);
532
    assert_eq!(app.collection.active_server, 2);
533
    handle_key(&mut app, key(KeyCode::Esc));
534
    assert_eq!(app.popup, Popup::None);
535
}
536
537
#[test]
538
fn auth_popup_edits_and_applies() {
539
    use cielago::model::AuthKind;
540
541
    let mut app = test_app();
542
    handle_key(&mut app, char_key('A'));
543
    assert_eq!(app.popup, Popup::Auth);
544
    // Field 0 is the kind toggle, defaulting to bearer for a fresh config.
545
    // Cycle it to oauth2 (bearer -> apikey -> oauth2).
546
    handle_key(&mut app, char_key(' '));
547
    handle_key(&mut app, char_key(' '));
548
    assert_eq!(app.auth_form.kind, AuthKind::Oauth2);
549
    // Now the oauth rows show: 1 = token url, 2 = client id, 5 = style.
550
    handle_key(&mut app, char_key('j'));
551
    handle_key(&mut app, char_key('i'));
552
    type_str(&mut app, "https://auth.example.com/token");
553
    handle_key(&mut app, key(KeyCode::Enter));
554
    handle_key(&mut app, char_key('j'));
555
    handle_key(&mut app, char_key('i'));
556
    type_str(&mut app, "my-client");
557
    handle_key(&mut app, key(KeyCode::Enter));
558
    // style toggle: field 5
559
    for _ in 0..3 {
560
        handle_key(&mut app, char_key('j'));
561
    }
562
    assert_eq!(app.auth_field, 5);
563
    handle_key(&mut app, char_key(' '));
564
    // close + apply
565
    handle_key(&mut app, key(KeyCode::Esc));
566
    assert_eq!(app.popup, Popup::None);
567
    let auth = app.collection.auth.as_ref().unwrap();
568
    assert_eq!(auth.kind, AuthKind::Oauth2);
569
    assert_eq!(auth.token_url, "https://auth.example.com/token");
570
    assert_eq!(auth.client_id, "my-client");
571
    assert_eq!(auth.auth_style, cielago::model::AuthStyle::Post);
572
    assert!(app.dirty);
573
}
574
575
#[test]
576
fn auth_popup_sets_bearer_token() {
577
    use cielago::model::AuthKind;
578
579
    let mut app = test_app();
580
    handle_key(&mut app, char_key('A'));
581
    // Defaults to bearer; field 1 is the token.
582
    assert_eq!(app.auth_form.kind, AuthKind::Bearer);
583
    handle_key(&mut app, char_key('j'));
584
    handle_key(&mut app, char_key('i'));
585
    type_str(&mut app, "sk-live-123");
586
    handle_key(&mut app, key(KeyCode::Enter));
587
    handle_key(&mut app, key(KeyCode::Esc));
588
589
    let auth = app.collection.auth.as_ref().unwrap();
590
    assert_eq!(auth.kind, AuthKind::Bearer);
591
    assert_eq!(auth.token, "sk-live-123");
592
}
593
594
#[test]
595
fn auth_popup_sets_api_key_header() {
596
    use cielago::model::AuthKind;
597
598
    let mut app = test_app();
599
    handle_key(&mut app, char_key('A'));
600
    // bearer -> apikey.
601
    handle_key(&mut app, char_key(' '));
602
    assert_eq!(app.auth_form.kind, AuthKind::ApiKey);
603
    // apikey rows: 1 = header name, 2 = value.
604
    handle_key(&mut app, char_key('j'));
605
    handle_key(&mut app, char_key('i'));
606
    type_str(&mut app, "X-Custom-Key");
607
    handle_key(&mut app, key(KeyCode::Enter));
608
    handle_key(&mut app, char_key('j'));
609
    handle_key(&mut app, char_key('i'));
610
    type_str(&mut app, "abc123");
611
    handle_key(&mut app, key(KeyCode::Enter));
612
    handle_key(&mut app, key(KeyCode::Esc));
613
614
    let auth = app.collection.auth.as_ref().unwrap();
615
    assert_eq!(auth.kind, AuthKind::ApiKey);
616
    assert_eq!(auth.header, "X-Custom-Key");
617
    assert_eq!(auth.token, "abc123");
618
    assert_eq!(auth.api_key_header(), "X-Custom-Key");
619
}
620
621
#[test]
622
fn new_request_flow() {
623
    let mut app = test_app();
624
    handle_key(&mut app, char_key('1'));
625
    handle_key(&mut app, char_key('n'));
626
    type_str(&mut app, "my custom request");
627
    handle_key(&mut app, key(KeyCode::Enter));
628
    assert_eq!(app.collection.requests.len(), 4);
629
    assert_eq!(app.selected, Some(3));
630
    assert_eq!(app.collection.requests[3].name, "my custom request");
631
}
632
633
#[test]
634
fn rename_and_delete_request() {
635
    let mut app = test_app();
636
    handle_key(&mut app, char_key('1'));
637
    handle_key(&mut app, char_key('j')); // first request row
638
    handle_key(&mut app, char_key('r'));
639
    // rename input prefilled with current name; replace
640
    handle_key(&mut app, key(KeyCode::Home));
641
    for _ in 0..20 {
642
        handle_key(&mut app, key(KeyCode::Delete));
643
    }
644
    type_str(&mut app, "renamed");
645
    handle_key(&mut app, key(KeyCode::Enter));
646
    assert_eq!(app.collection.requests[0].name, "renamed");
647
648
    // focus stays in the sidebar on the renamed row; delete it
649
    assert_eq!(app.focus, Focus::Sidebar);
650
    handle_key(&mut app, char_key('d'));
651
    assert_eq!(app.collection.requests.len(), 2);
652
    assert!(!app.collection.requests.iter().any(|r| r.name == "renamed"));
653
}
654
655
#[test]
656
fn variables_tab_roundtrip() {
657
    let mut app = test_app();
658
    handle_key(&mut app, char_key('2'));
659
    handle_key(&mut app, char_key('[')); // Variables (prev of Params)
660
    assert_eq!(app.tab, EditorTab::Variables);
661
    handle_key(&mut app, char_key('a'));
662
    type_str(&mut app, "tenant");
663
    handle_key(&mut app, key(KeyCode::Enter));
664
    type_str(&mut app, "acme");
665
    handle_key(&mut app, key(KeyCode::Enter));
666
    assert_eq!(app.collection.variables.len(), 1);
667
    let map = cielago::model::variables_map(&app.collection.variables);
668
    assert_eq!(map.get("tenant").unwrap(), "acme");
669
}
670
671
// ----- ad-hoc requests and collections -----
672
673
/// Open the URL prompt on the selected request with a cleared buffer.
674
fn start_url_edit(app: &mut App) {
675
    handle_key(app, char_key('2'));
676
    handle_key(app, char_key('p'));
677
    assert_eq!(app.editing, Some(EditTarget::Url));
678
    handle_key(app, key(KeyCode::Home));
679
    for _ in 0..60 {
680
        handle_key(app, key(KeyCode::Delete));
681
    }
682
}
683
684
fn type_url(app: &mut App, url: &str) {
685
    start_url_edit(app);
686
    type_str(app, url);
687
    handle_key(app, key(KeyCode::Enter));
688
}
689
690
#[test]
691
fn edit_url_sets_path_and_syncs_path_params() {
692
    let mut app = test_app();
693
    type_url(&mut app, "/pets/{petId}/photos");
694
695
    let req = &app.collection.requests[0];
696
    assert_eq!(req.path, "/pets/{petId}/photos");
697
    assert_eq!(req.path_params.len(), 1);
698
    assert_eq!(req.path_params[0].key, "petId");
699
    assert!(app.dirty);
700
    // No `?` in the input, so the existing query rows are untouched.
701
    assert_eq!(req.query.len(), 2);
702
703
    // Removing the placeholder prunes the row again.
704
    type_url(&mut app, "/pets");
705
    assert!(app.collection.requests[0].path_params.is_empty());
706
}
707
708
#[test]
709
fn pasting_a_full_url_adds_and_activates_the_server() {
710
    let mut app = test_app();
711
    type_url(
712
        &mut app,
713
        "https://three.example.com/v2/pets?limit=5&sort=name",
714
    );
715
716
    assert_eq!(app.collection.servers.len(), 3);
717
    assert_eq!(app.collection.active_server, 2);
718
    assert_eq!(app.collection.base_url(), Some("https://three.example.com"));
719
    let req = &app.collection.requests[0];
720
    assert_eq!(req.path, "/v2/pets");
721
    let query: Vec<(&str, &str)> = req
722
        .query
723
        .iter()
724
        .map(|r| (r.key.as_str(), r.value.as_str()))
725
        .collect();
726
    assert_eq!(query, vec![("limit", "5"), ("sort", "name")]);
727
    assert!(req.query.iter().all(|r| r.enabled));
728
}
729
730
#[test]
731
fn pasting_a_known_origin_switches_to_it_without_duplicating() {
732
    let mut app = test_app();
733
    assert_eq!(app.collection.active_server, 0);
734
    type_url(&mut app, "https://two.example.com/pets");
735
736
    assert_eq!(app.collection.servers.len(), 2);
737
    assert_eq!(app.collection.active_server, 1);
738
    assert_eq!(app.collection.requests[0].path, "/pets");
739
}
740
741
#[test]
742
fn pasting_without_a_query_keeps_existing_params() {
743
    let mut app = test_app();
744
    type_url(&mut app, "https://one.example.com/pets/all");
745
746
    let req = &app.collection.requests[0];
747
    assert_eq!(req.path, "/pets/all");
748
    assert_eq!(req.query.len(), 2);
749
    // Disabled optional params from the fixture survive untouched.
750
    assert!(req.query.iter().all(|r| !r.enabled));
751
    assert_eq!(req.query[0].key, "limit");
752
}
753
754
#[test]
755
fn non_http_scheme_is_rejected() {
756
    let mut app = test_app();
757
    type_url(&mut app, "ftp://files.example.com/pets");
758
759
    assert_eq!(app.collection.requests[0].path, "/pets");
760
    assert_eq!(app.collection.servers.len(), 2);
761
    assert!(app.status.contains("http(s)"));
762
}
763
764
#[test]
765
fn duplicate_request_clones_directly_after_the_original() {
766
    let mut app = test_app();
767
    handle_key(&mut app, char_key('1'));
768
    handle_key(&mut app, char_key('j')); // first request row (listPets)
769
    handle_key(&mut app, char_key('y'));
770
771
    assert_eq!(app.collection.requests.len(), 4);
772
    assert_eq!(app.collection.requests[1].name, "listPets copy");
773
    assert_ne!(app.collection.requests[0].id, app.collection.requests[1].id);
774
    assert_eq!(app.collection.requests[1].path, "/pets");
775
    assert_eq!(app.collection.requests[1].query.len(), 2);
776
    // The original is still in place, and the clone is what's open.
777
    assert_eq!(app.collection.requests[0].name, "listPets");
778
    assert_eq!(app.selected, Some(1));
779
    assert_eq!(app.focus, Focus::Sidebar);
780
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(1));
781
    assert!(app.dirty);
782
}
783
784
#[test]
785
fn duplicate_twice_numbers_the_copies() {
786
    let mut app = test_app();
787
    handle_key(&mut app, char_key('1'));
788
    handle_key(&mut app, char_key('j'));
789
    handle_key(&mut app, char_key('y'));
790
    handle_key(&mut app, char_key('y'));
791
792
    assert_eq!(app.collection.requests.len(), 5);
793
    let names: Vec<&str> = app
794
        .collection
795
        .requests
796
        .iter()
797
        .map(|r| r.name.as_str())
798
        .collect();
799
    // The cursor followed the first clone, so the second `y` duplicates *it* —
800
    // and the ` copy` suffix is stripped first, giving `copy 2` not `copy copy`.
801
    assert_eq!(names[1], "listPets copy");
802
    assert_eq!(names[2], "listPets copy 2");
803
}
804
805
#[test]
806
fn new_request_chains_into_the_url_prompt() {
807
    let mut app = test_app();
808
    handle_key(&mut app, char_key('1'));
809
    handle_key(&mut app, char_key('n'));
810
    type_str(&mut app, "adhoc");
811
    handle_key(&mut app, key(KeyCode::Enter));
812
813
    // The name commit leaves you in the URL prompt rather than on `GET /`.
814
    assert_eq!(app.editing, Some(EditTarget::Url));
815
    assert_eq!(app.mode, Mode::Insert);
816
    type_str(&mut app, "https://four.example.com/ip");
817
    handle_key(&mut app, key(KeyCode::Enter));
818
819
    let req = app.collection.requests.last().unwrap();
820
    assert_eq!(req.name, "adhoc");
821
    assert_eq!(req.path, "/ip");
822
    assert_eq!(app.collection.base_url(), Some("https://four.example.com"));
823
}
824
825
#[test]
826
fn switch_collection_replaces_state() {
827
    let mut app = test_app();
828
    handle_key(&mut app, char_key('/'));
829
    type_str(&mut app, "orders");
830
    handle_key(&mut app, key(KeyCode::Enter));
831
    handle_key(&mut app, char_key('2'));
832
    handle_key(&mut app, char_key('m')); // dirty it
833
834
    let mut other = Collection::new("other");
835
    other.requests = vec![SavedRequest::blank("only")];
836
    app.switch_collection(other, PathBuf::from("/tmp/cielago-other.json"));
837
838
    assert_eq!(app.collection.name, "other");
839
    assert_eq!(app.collection.requests.len(), 1);
840
    assert_eq!(app.selected, Some(0));
841
    assert!(!app.dirty);
842
    assert!(app.filter.is_empty());
843
    assert!(app.response.is_none());
844
    assert!(app.status.contains("other"));
845
    // Tracked in memory only — `switch_collection` must not write to the real
846
    // `~/.config/cielago`, which is where `store::config_dir` always points.
847
    assert_eq!(app.config.last_collection.as_deref(), Some("other"));
848
}
849
850
#[test]
851
fn new_collection_command_refuses_when_dirty() {
852
    let mut app = test_app();
853
    handle_key(&mut app, char_key('2'));
854
    handle_key(&mut app, char_key('m')); // cycle method → dirty
855
    assert!(app.dirty);
856
857
    // The dirty guard runs before any filesystem access, so this touches nothing.
858
    handle_key(&mut app, char_key(':'));
859
    type_str(&mut app, "new Scratch");
860
    handle_key(&mut app, key(KeyCode::Enter));
861
862
    assert_eq!(app.collection.name, "test");
863
    assert!(app.status.contains("Unsaved changes"));
864
}
865
866
#[test]
867
fn new_collection_command_rejects_a_missing_name() {
868
    let mut app = test_app();
869
    handle_key(&mut app, char_key(':'));
870
    type_str(&mut app, "new  ");
871
    handle_key(&mut app, key(KeyCode::Enter));
872
873
    assert_eq!(app.collection.name, "test");
874
    assert!(app.status.starts_with("Usage: :new"));
875
}