golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/relui/web_test.go (about) 1 // Copyright 2020 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package relui 6 7 import ( 8 "context" 9 "database/sql" 10 "embed" 11 "flag" 12 "fmt" 13 "io" 14 "log" 15 "net/http" 16 "net/http/httptest" 17 "net/url" 18 "os" 19 "path" 20 "strings" 21 "sync" 22 "testing" 23 "time" 24 25 "github.com/google/go-cmp/cmp" 26 "github.com/google/go-cmp/cmp/cmpopts" 27 "github.com/google/uuid" 28 "github.com/jackc/pgx/v4" 29 "github.com/jackc/pgx/v4/pgxpool" 30 "github.com/julienschmidt/httprouter" 31 "golang.org/x/build/internal/releasetargets" 32 "golang.org/x/build/internal/relui/db" 33 "golang.org/x/build/internal/workflow" 34 ) 35 36 // testStatic is our static web server content. 37 // 38 //go:embed testing 39 var testStatic embed.FS 40 41 func TestFileServerHandler(t *testing.T) { 42 cases := []struct { 43 desc string 44 path string 45 wantCode int 46 wantBody string 47 wantHeaders map[string]string 48 }{ 49 { 50 desc: "sets headers and returns file", 51 path: "/testing/test.css", 52 wantCode: http.StatusOK, 53 wantBody: "/**\n * Copyright 2022 The Go Authors. All rights reserved.\n " + 54 "* Use of this source code is governed by a BSD-style\n " + 55 "* license that can be found in the LICENSE file.\n */\n\n.Header { font-size: 10rem; }\n", 56 wantHeaders: map[string]string{ 57 "Content-Type": "text/css; charset=utf-8", 58 "Cache-Control": "no-cache, private, max-age=0", 59 }, 60 }, 61 { 62 desc: "handles missing file", 63 path: "/foo.js", 64 wantCode: http.StatusNotFound, 65 wantBody: "404 page not found\n", 66 wantHeaders: map[string]string{ 67 "Content-Type": "text/plain; charset=utf-8", 68 }, 69 }, 70 } 71 for _, c := range cases { 72 t.Run(c.desc, func(t *testing.T) { 73 req := httptest.NewRequest(http.MethodGet, c.path, nil) 74 w := httptest.NewRecorder() 75 76 m := &metricsRouter{router: httprouter.New()} 77 m.ServeFiles("/*filepath", http.FS(testStatic)) 78 m.ServeHTTP(w, req) 79 resp := w.Result() 80 defer resp.Body.Close() 81 82 if resp.StatusCode != c.wantCode { 83 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode) 84 } 85 b, err := io.ReadAll(resp.Body) 86 if err != nil { 87 t.Errorf("resp.Body = _, %v, wanted no error", err) 88 } 89 if string(b) != c.wantBody { 90 t.Errorf("resp.Body = %q, %v, wanted %q, %v", b, err, c.wantBody, nil) 91 } 92 for k, v := range c.wantHeaders { 93 if resp.Header.Get(k) != v { 94 t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v) 95 } 96 } 97 }) 98 } 99 } 100 101 func TestServerHomeHandler(t *testing.T) { 102 ctx, cancel := context.WithCancel(context.Background()) 103 defer cancel() 104 p := testDB(ctx, t) 105 106 q := db.New(p) 107 wf := db.CreateWorkflowParams{ID: uuid.New(), Name: nullString("test workflow")} 108 if _, err := q.CreateWorkflow(ctx, wf); err != nil { 109 t.Fatalf("CreateWorkflow(_, %v) = _, %v, wanted no error", wf, err) 110 } 111 tp := db.CreateTaskParams{ 112 WorkflowID: wf.ID, 113 Name: "TestTask", 114 Result: nullString(`{"Filename": "foo.exe"}`), 115 } 116 if _, err := q.CreateTask(ctx, tp); err != nil { 117 t.Fatalf("CreateTask(_, %v) = _, %v, wanted no error", tp, err) 118 } 119 120 req := httptest.NewRequest(http.MethodGet, "/", nil) 121 w := httptest.NewRecorder() 122 123 s := NewServer(p, NewWorker(NewDefinitionHolder(), p, &PGListener{DB: p}), nil, SiteHeader{}, nil) 124 125 s.homeHandler(w, req) 126 resp := w.Result() 127 128 if resp.StatusCode != http.StatusOK { 129 t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK) 130 } 131 } 132 133 func TestServerNewWorkflowHandler(t *testing.T) { 134 cases := []struct { 135 desc string 136 params url.Values 137 wantCode int 138 }{ 139 { 140 desc: "No selection", 141 wantCode: http.StatusOK, 142 }, 143 { 144 desc: "valid workflow", 145 params: url.Values{"workflow.name": []string{"echo"}}, 146 wantCode: http.StatusOK, 147 }, 148 { 149 desc: "invalid workflow", 150 params: url.Values{"workflow.name": []string{"this workflow does not exist"}}, 151 wantCode: http.StatusOK, 152 }, 153 } 154 for _, c := range cases { 155 t.Run(c.desc, func(t *testing.T) { 156 ctx, cancel := context.WithCancel(context.Background()) 157 defer cancel() 158 159 u := url.URL{Path: "/new_workflow", RawQuery: c.params.Encode()} 160 req := httptest.NewRequest(http.MethodGet, u.String(), nil) 161 w := httptest.NewRecorder() 162 163 s := NewServer(testDB(ctx, t), NewWorker(NewDefinitionHolder(), nil, nil), nil, SiteHeader{}, nil) 164 s.newWorkflowHandler(w, req) 165 resp := w.Result() 166 167 if resp.StatusCode != http.StatusOK { 168 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK) 169 } 170 }) 171 } 172 } 173 174 func TestServerCreateWorkflowHandler(t *testing.T) { 175 ctx, cancel := context.WithCancel(context.Background()) 176 defer cancel() 177 178 now := time.Now() 179 cases := []struct { 180 desc string 181 params url.Values 182 wantCode int 183 wantWorkflows []db.Workflow 184 wantSchedules []db.Schedule 185 }{ 186 { 187 desc: "no params", 188 wantCode: http.StatusBadRequest, 189 }, 190 { 191 desc: "invalid workflow name", 192 params: url.Values{ 193 "workflow.name": []string{"invalid"}, 194 "workflow.schedule": []string{string(ScheduleImmediate)}, 195 }, 196 wantCode: http.StatusBadRequest, 197 }, 198 { 199 desc: "missing workflow params", 200 params: url.Values{ 201 "workflow.name": []string{"echo"}, 202 "workflow.schedule": []string{string(ScheduleImmediate)}, 203 }, 204 wantCode: http.StatusBadRequest, 205 }, 206 { 207 desc: "successful creation", 208 params: url.Values{ 209 "workflow.name": []string{"echo"}, 210 "workflow.params.greeting": []string{"hello"}, 211 "workflow.params.farewell": []string{"bye"}, 212 "workflow.schedule": []string{string(ScheduleImmediate)}, 213 }, 214 wantCode: http.StatusSeeOther, 215 wantWorkflows: []db.Workflow{ 216 { 217 ID: uuid.New(), // SameUUIDVariant 218 Params: nullString(`{"farewell": "bye", "greeting": "hello"}`), 219 Name: nullString(`echo`), 220 Output: "{}", 221 CreatedAt: now, // cmpopts.EquateApproxTime 222 UpdatedAt: now, // cmpopts.EquateApproxTime 223 }, 224 }, 225 }, 226 { 227 desc: "successful creation: schedule immediate", 228 params: url.Values{ 229 "workflow.name": []string{"echo"}, 230 "workflow.params.greeting": []string{"hello"}, 231 "workflow.params.farewell": []string{"bye"}, 232 "workflow.schedule": []string{string(ScheduleImmediate)}, 233 }, 234 wantCode: http.StatusSeeOther, 235 wantWorkflows: []db.Workflow{ 236 { 237 ID: uuid.New(), // SameUUIDVariant 238 Params: nullString(`{"farewell": "bye", "greeting": "hello"}`), 239 Name: nullString(`echo`), 240 Output: "{}", 241 CreatedAt: now, // cmpopts.EquateApproxTime 242 UpdatedAt: now, // cmpopts.EquateApproxTime 243 }, 244 }, 245 }, 246 { 247 desc: "successful creation: schedule once", 248 params: url.Values{ 249 "workflow.name": []string{"echo"}, 250 "workflow.params.greeting": []string{"hello"}, 251 "workflow.params.farewell": []string{"bye"}, 252 "workflow.schedule": []string{string(ScheduleOnce)}, 253 "workflow.schedule.datetime": []string{now.UTC().AddDate(1, 0, 0).Format(DatetimeLocalLayout)}, 254 }, 255 wantCode: http.StatusSeeOther, 256 wantSchedules: []db.Schedule{ 257 { 258 WorkflowName: "echo", 259 WorkflowParams: nullString(`{"farewell": "bye", "greeting": "hello"}`), 260 Once: now.UTC().AddDate(1, 0, 0), 261 CreatedAt: now, // cmpopts.EquateApproxTime 262 UpdatedAt: now, // cmpopts.EquateApproxTime 263 }, 264 }, 265 }, 266 { 267 desc: "successful creation: schedule cron", 268 params: url.Values{ 269 "workflow.name": []string{"echo"}, 270 "workflow.params.greeting": []string{"hello"}, 271 "workflow.params.farewell": []string{"bye"}, 272 "workflow.schedule": []string{string(ScheduleCron)}, 273 "workflow.schedule.cron": []string{"0 0 1 1 0"}, 274 }, 275 wantCode: http.StatusSeeOther, 276 wantSchedules: []db.Schedule{ 277 { 278 WorkflowName: "echo", 279 WorkflowParams: nullString(`{"farewell": "bye", "greeting": "hello"}`), 280 Spec: "0 0 1 1 0", 281 CreatedAt: now, // cmpopts.EquateApproxTime 282 UpdatedAt: now, // cmpopts.EquateApproxTime 283 }, 284 }, 285 }, 286 } 287 for _, c := range cases { 288 t.Run(c.desc, func(t *testing.T) { 289 p := testDB(ctx, t) 290 req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode())) 291 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 292 rec := httptest.NewRecorder() 293 q := db.New(p) 294 295 s := NewServer(p, NewWorker(NewDefinitionHolder(), p, &PGListener{DB: p}), nil, SiteHeader{}, nil) 296 s.createWorkflowHandler(rec, req) 297 resp := rec.Result() 298 299 if resp.StatusCode != c.wantCode { 300 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode) 301 if resp.StatusCode == http.StatusBadRequest { 302 b, _ := io.ReadAll(resp.Body) 303 t.Logf("resp.Body: \n%v", string(b)) 304 } 305 } 306 if c.wantCode == http.StatusBadRequest { 307 return 308 } 309 wfs, err := q.Workflows(ctx) 310 if err != nil { 311 t.Fatalf("q.Workflows() = %v, %v, wanted no error", wfs, err) 312 } 313 if diff := cmp.Diff(c.wantWorkflows, wfs, SameUUIDVariant(), cmpopts.EquateApproxTime(time.Minute)); diff != "" { 314 t.Fatalf("q.Workflows() mismatch (-want +got):\n%s", diff) 315 } 316 scheds, err := q.Schedules(ctx) 317 if err != nil { 318 t.Fatalf("q.Schedules() = %v, %v, wanted no error", scheds, err) 319 } 320 if diff := cmp.Diff(c.wantSchedules, scheds, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.Schedule{}, "ID")); diff != "" { 321 t.Fatalf("q.Schedules() mismatch (-want +got):\n%s", diff) 322 } 323 if c.wantCode == http.StatusSeeOther && len(c.wantSchedules) == 0 { 324 got := resp.Header.Get("Location") 325 want := path.Join("/workflows", wfs[0].ID.String()) 326 if got != want { 327 t.Fatalf("resp.Headers.Get(%q) = %q, wanted %q", "Location", got, want) 328 } 329 } 330 }) 331 } 332 } 333 334 // resetDB truncates the db connected to in the pgxpool.Pool 335 // connection. 336 // 337 // All tables in the public schema of the connected database will be 338 // truncated except for the migrations table. 339 func resetDB(ctx context.Context, t *testing.T, p *pgxpool.Pool) { 340 t.Helper() 341 tableQuery := `SELECT table_name FROM information_schema.tables WHERE table_schema='public'` 342 rows, err := p.Query(ctx, tableQuery) 343 if err != nil { 344 t.Fatalf("p.Query(_, %q, %q) = %v, %v, wanted no error", tableQuery, "public", rows, err) 345 } 346 defer rows.Close() 347 for rows.Next() { 348 var name string 349 if err := rows.Scan(&name); err != nil { 350 t.Fatalf("rows.Scan() = %v, wanted no error", err) 351 } 352 if name == "migrations" { 353 continue 354 } 355 truncQ := fmt.Sprintf("TRUNCATE %s CASCADE", pgx.Identifier{name}.Sanitize()) 356 c, err := p.Exec(ctx, truncQ) 357 if err != nil { 358 t.Fatalf("p.Exec(_, %q) = %v, %v", truncQ, c, err) 359 } 360 } 361 if err := rows.Err(); err != nil { 362 log.Fatalf("rows.Err() = %v, wanted no error", err) 363 } 364 } 365 366 var testPoolOnce sync.Once 367 var testPool *pgxpool.Pool 368 369 var flagTestDB = flag.String("test-pgdatabase", os.Getenv("PGDATABASE"), "postgres database to use for testing") 370 371 // testDB connects, creates, and migrates a database in preparation 372 // for testing, and returns a connection pool to the prepared 373 // database. 374 // 375 // The connection pool is closed as part of a t.Cleanup handler. 376 // Database connections are expected to be configured through libpq 377 // compatible environment variables. If no PGDATABASE is specified, 378 // relui-test will be used. 379 // 380 // https://www.postgresql.org/docs/current/libpq-envars.html 381 func testDB(ctx context.Context, t *testing.T) *pgxpool.Pool { 382 t.Helper() 383 if testing.Short() { 384 t.Skip("Skipping database tests in short mode.") 385 } 386 testPoolOnce.Do(func() { 387 pgdb := url.QueryEscape(*flagTestDB) 388 if pgdb == "" { 389 pgdb = "relui-test" 390 } 391 if err := InitDB(ctx, fmt.Sprintf("database=%v", pgdb)); err != nil { 392 t.Skipf("Skipping database integration test: %v", err) 393 } 394 p, err := pgxpool.Connect(ctx, fmt.Sprintf("database=%v", pgdb)) 395 if err != nil { 396 t.Skipf("Skipping database integration test: %v", err) 397 } 398 testPool = p 399 }) 400 if testPool == nil { 401 t.Skip("Skipping database integration test: testdb = nil. See first error for details.") 402 return nil 403 } 404 t.Cleanup(func() { 405 resetDB(context.Background(), t, testPool) 406 }) 407 return testPool 408 } 409 410 // SameUUIDVariant considers UUIDs equal if they are both the same 411 // uuid.Variant. Zero-value uuids are considered equal. 412 func SameUUIDVariant() cmp.Option { 413 return cmp.Transformer("SameVariant", func(v uuid.UUID) uuid.Variant { 414 return v.Variant() 415 }) 416 } 417 418 func TestSameUUIDVariant(t *testing.T) { 419 cases := []struct { 420 desc string 421 x uuid.UUID 422 y uuid.UUID 423 want bool 424 }{ 425 { 426 desc: "both set", 427 x: uuid.New(), 428 y: uuid.New(), 429 want: true, 430 }, 431 { 432 desc: "both unset", 433 want: true, 434 }, 435 { 436 desc: "just x", 437 x: uuid.New(), 438 want: false, 439 }, 440 { 441 desc: "just y", 442 y: uuid.New(), 443 want: false, 444 }, 445 } 446 for _, c := range cases { 447 t.Run(c.desc, func(t *testing.T) { 448 if got := cmp.Equal(c.x, c.y, SameUUIDVariant()); got != c.want { 449 t.Fatalf("cmp.Equal(%v, %v, SameUUIDVariant()) = %t, wanted %t", c.x, c.y, got, c.want) 450 } 451 }) 452 } 453 } 454 455 // nullString returns a sql.NullString for a string. 456 func nullString(val string) sql.NullString { 457 return sql.NullString{String: val, Valid: true} 458 } 459 460 func TestServerBaseLink(t *testing.T) { 461 cases := []struct { 462 desc string 463 baseURL string 464 target string 465 extras []string 466 want string 467 }{ 468 { 469 desc: "no baseURL, relative", 470 target: "/workflows", 471 want: "/workflows", 472 }, 473 { 474 desc: "no baseURL, absolute", 475 target: "https://example.test/something", 476 want: "https://example.test/something", 477 }, 478 { 479 desc: "absolute baseURL, relative", 480 baseURL: "https://example.test/releases", 481 target: "/workflows", 482 want: "https://example.test/releases/workflows", 483 }, 484 { 485 desc: "relative baseURL, relative", 486 baseURL: "/releases", 487 target: "/workflows", 488 want: "/releases/workflows", 489 }, 490 { 491 desc: "relative baseURL, relative with extras", 492 baseURL: "/releases", 493 target: "/workflows", 494 extras: []string{"a-workflow"}, 495 want: "/releases/workflows/a-workflow", 496 }, 497 { 498 desc: "absolute baseURL, absolute", 499 baseURL: "https://example.test/releases", 500 target: "https://example.test/something", 501 want: "https://example.test/something", 502 }, 503 { 504 desc: "absolute baseURL, absolute with extras", 505 baseURL: "https://example.test/releases", 506 target: "https://example.test/something", 507 extras: []string{"else"}, 508 want: "https://example.test/something/else", 509 }, 510 } 511 for _, c := range cases { 512 t.Run(c.desc, func(t *testing.T) { 513 base, err := url.Parse(c.baseURL) 514 if err != nil { 515 t.Fatalf("url.Parse(%q) = %v, %v, wanted no error", c.baseURL, base, err) 516 } 517 s := &Server{baseURL: base} 518 519 got := s.BaseLink(c.target, c.extras...) 520 if got != c.want { 521 t.Errorf("s.BaseLink(%q) = %q, wanted %q", c.target, got, c.want) 522 } 523 }) 524 } 525 } 526 527 func TestServerApproveTaskHandler(t *testing.T) { 528 ctx, cancel := context.WithCancel(context.Background()) 529 defer cancel() 530 531 hourAgo := time.Now().Add(-1 * time.Hour) 532 wfID := uuid.New() 533 534 cases := []struct { 535 desc string 536 params map[string]string 537 wantCode int 538 wantHeaders map[string]string 539 want db.Task 540 }{ 541 { 542 desc: "no params", 543 wantCode: http.StatusNotFound, 544 want: db.Task{ 545 WorkflowID: wfID, 546 Name: "approve please", 547 CreatedAt: hourAgo, 548 UpdatedAt: hourAgo, 549 }, 550 }, 551 { 552 desc: "invalid workflow id", 553 params: map[string]string{"id": "invalid", "name": "greeting"}, 554 wantCode: http.StatusBadRequest, 555 want: db.Task{ 556 WorkflowID: wfID, 557 Name: "approve please", 558 CreatedAt: hourAgo, 559 UpdatedAt: hourAgo, 560 }, 561 }, 562 { 563 desc: "wrong workflow id", 564 params: map[string]string{"id": uuid.New().String(), "name": "greeting"}, 565 wantCode: http.StatusNotFound, 566 want: db.Task{ 567 WorkflowID: wfID, 568 Name: "approve please", 569 CreatedAt: hourAgo, 570 UpdatedAt: hourAgo, 571 }, 572 }, 573 { 574 desc: "invalid task name", 575 params: map[string]string{"id": wfID.String(), "name": "invalid"}, 576 wantCode: http.StatusNotFound, 577 want: db.Task{ 578 WorkflowID: wfID, 579 Name: "approve please", 580 CreatedAt: hourAgo, 581 UpdatedAt: hourAgo, 582 }, 583 }, 584 { 585 desc: "successful approval", 586 params: map[string]string{"id": wfID.String(), "name": "approve please"}, 587 wantCode: http.StatusSeeOther, 588 wantHeaders: map[string]string{ 589 "Location": path.Join("/workflows", wfID.String()), 590 }, 591 want: db.Task{ 592 WorkflowID: wfID, 593 Name: "approve please", 594 CreatedAt: hourAgo, 595 UpdatedAt: time.Now(), 596 ApprovedAt: sql.NullTime{Time: time.Now(), Valid: true}, 597 }, 598 }, 599 } 600 for _, c := range cases { 601 t.Run(c.desc, func(t *testing.T) { 602 p := testDB(ctx, t) 603 q := db.New(p) 604 605 wf := db.CreateWorkflowParams{ 606 ID: wfID, 607 Params: nullString(`{"farewell": "bye", "greeting": "hello"}`), 608 Name: nullString(`echo`), 609 CreatedAt: hourAgo, 610 UpdatedAt: hourAgo, 611 } 612 if _, err := q.CreateWorkflow(ctx, wf); err != nil { 613 t.Fatalf("CreateWorkflow(_, %v) = _, %v, wanted no error", wf, err) 614 } 615 gtg := db.CreateTaskParams{ 616 WorkflowID: wf.ID, 617 Name: "approve please", 618 Finished: false, 619 CreatedAt: hourAgo, 620 UpdatedAt: hourAgo, 621 } 622 if _, err := q.CreateTask(ctx, gtg); err != nil { 623 t.Fatalf("CreateTask(_, %v) = _, %v, wanted no error", gtg, err) 624 } 625 626 req := httptest.NewRequest(http.MethodPost, path.Join("/workflows/", c.params["id"], "tasks", url.PathEscape(c.params["name"]), "approve"), nil) 627 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 628 rec := httptest.NewRecorder() 629 s := NewServer(p, NewWorker(NewDefinitionHolder(), p, &PGListener{DB: p}), nil, SiteHeader{}, nil) 630 631 s.m.ServeHTTP(rec, req) 632 resp := rec.Result() 633 634 if resp.StatusCode != c.wantCode { 635 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode) 636 } 637 for k, v := range c.wantHeaders { 638 if resp.Header.Get(k) != v { 639 t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v) 640 } 641 } 642 if c.wantCode == http.StatusBadRequest { 643 return 644 } 645 task, err := q.Task(ctx, db.TaskParams{ 646 WorkflowID: wf.ID, 647 Name: "approve please", 648 }) 649 if err != nil { 650 t.Fatalf("q.Task() = %v, %v, wanted no error", task, err) 651 } 652 if diff := cmp.Diff(c.want, task, cmpopts.EquateApproxTime(time.Minute)); diff != "" { 653 t.Fatalf("q.Task() mismatch (-want +got):\n%s", diff) 654 } 655 }) 656 } 657 } 658 659 func TestServerStopWorkflow(t *testing.T) { 660 wfID := uuid.New() 661 cases := []struct { 662 desc string 663 params map[string]string 664 wantCode int 665 wantHeaders map[string]string 666 wantLogs []db.TaskLog 667 wantCancel bool 668 }{ 669 { 670 desc: "no params", 671 wantCode: http.StatusMethodNotAllowed, 672 }, 673 { 674 desc: "invalid workflow id", 675 params: map[string]string{"id": "invalid"}, 676 wantCode: http.StatusBadRequest, 677 }, 678 { 679 desc: "wrong workflow id", 680 params: map[string]string{"id": uuid.New().String()}, 681 wantCode: http.StatusNotFound, 682 }, 683 { 684 desc: "successful stop", 685 params: map[string]string{"id": wfID.String()}, 686 wantCode: http.StatusSeeOther, 687 wantHeaders: map[string]string{ 688 "Location": "/", 689 }, 690 wantCancel: true, 691 }, 692 } 693 for _, c := range cases { 694 t.Run(c.desc, func(t *testing.T) { 695 ctx, cancel := context.WithCancel(context.Background()) 696 defer cancel() 697 698 req := httptest.NewRequest(http.MethodPost, path.Join("/workflows/", c.params["id"], "stop"), nil) 699 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 700 rec := httptest.NewRecorder() 701 worker := NewWorker(NewDefinitionHolder(), nil, nil) 702 703 wf := &workflow.Workflow{ID: wfID} 704 if err := worker.markRunning(wf, cancel); err != nil { 705 t.Fatalf("worker.markRunning(%v, %v) = %v, wanted no error", wf, cancel, err) 706 } 707 708 s := NewServer(testDB(ctx, t), worker, nil, SiteHeader{}, nil) 709 s.m.ServeHTTP(rec, req) 710 resp := rec.Result() 711 712 if resp.StatusCode != c.wantCode { 713 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode) 714 } 715 for k, v := range c.wantHeaders { 716 if resp.Header.Get(k) != v { 717 t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v) 718 } 719 } 720 if c.wantCancel { 721 <-ctx.Done() 722 return 723 } 724 if ctx.Err() != nil { 725 t.Errorf("tx.Err() = %v, wanted no error", ctx.Err()) 726 } 727 }) 728 } 729 } 730 731 func TestResultDetail(t *testing.T) { 732 cases := []struct { 733 desc string 734 input string 735 want *resultDetail 736 wantKind string 737 }{ 738 { 739 desc: "string", 740 input: `"hello"`, 741 want: &resultDetail{String: "hello"}, 742 wantKind: "String", 743 }, 744 { 745 desc: "artifact", 746 input: `{"Filename": "foo.exe", "Target": {"Name": "windows-test"}}`, 747 want: &resultDetail{Artifact: artifact{Filename: "foo.exe", Target: &releasetargets.Target{Name: "windows-test"}}}, 748 wantKind: "Artifact", 749 }, 750 { 751 desc: "artifact missing target", 752 input: `{"Filename": "foo.exe"}`, 753 want: &resultDetail{Artifact: artifact{Filename: "foo.exe"}}, 754 wantKind: "Artifact", 755 }, 756 { 757 desc: "nested json string", 758 input: `{"SomeOutput": "hello"}`, 759 want: &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {String: "hello"}}}, 760 wantKind: "Outputs", 761 }, 762 { 763 desc: "nested json complex", 764 input: `{"SomeOutput": {"Filename": "go.exe"}}`, 765 want: &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Artifact: artifact{Filename: "go.exe"}}}}, 766 wantKind: "Outputs", 767 }, 768 { 769 desc: "nested json slice", 770 input: `{"SomeOutput": [{"Filename": "go.exe"}]}`, 771 want: &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Slice: []*resultDetail{{ 772 Artifact: artifact{Filename: "go.exe"}, 773 }}}}}, 774 wantKind: "Outputs", 775 }, 776 { 777 desc: "nested json output", 778 input: `{"SomeOutput": {"OtherOutput": "go.exe", "Next": 123, "Thing": {"foo": "bar"}, "Sauces": ["cranberry", "pizza"]}}`, 779 want: &resultDetail{Outputs: map[string]*resultDetail{ 780 "SomeOutput": {Outputs: map[string]*resultDetail{ 781 "OtherOutput": {String: "go.exe"}, 782 "Next": {Number: 123}, 783 "Thing": {Outputs: map[string]*resultDetail{"foo": {String: "bar"}}}, 784 "Sauces": {Slice: []*resultDetail{{String: "cranberry"}, {String: "pizza"}}}, 785 }}}}, 786 wantKind: "Outputs", 787 }, 788 { 789 desc: "null json", 790 input: `null`, 791 }, 792 } 793 for _, c := range cases { 794 t.Run(c.desc, func(t *testing.T) { 795 got := unmarshalResultDetail(c.input) 796 797 if got.Kind() != c.wantKind { 798 t.Errorf("got.Kind() = %q, wanted %q", got.Kind(), c.wantKind) 799 } 800 if diff := cmp.Diff(c.want, got); diff != "" { 801 t.Errorf("unmarshalResultDetail mismatch (-want +got):\n%s", diff) 802 } 803 }) 804 } 805 }