|
4 | 4 | package slides |
5 | 5 |
|
6 | 6 | import ( |
| 7 | + "bytes" |
| 8 | + "context" |
| 9 | + "errors" |
7 | 10 | "reflect" |
8 | 11 | "strings" |
9 | 12 | "testing" |
| 13 | + "time" |
| 14 | + |
| 15 | + "github.com/larksuite/cli/errs" |
10 | 16 | ) |
11 | 17 |
|
12 | 18 | func TestParsePresentationRef(t *testing.T) { |
@@ -413,3 +419,145 @@ func TestEnsureXMLRootID(t *testing.T) { |
413 | 419 | }) |
414 | 420 | } |
415 | 421 | } |
| 422 | + |
| 423 | +func TestCheckXMLWellFormed(t *testing.T) { |
| 424 | + t.Parallel() |
| 425 | + |
| 426 | + tests := []struct { |
| 427 | + name string |
| 428 | + in string |
| 429 | + wantErr string |
| 430 | + }{ |
| 431 | + {name: "simple element", in: `<shape type="rect"><content/></shape>`}, |
| 432 | + {name: "nested with attributes", in: `<slide><shape type="text"><content><p>hi</p></content></shape></slide>`}, |
| 433 | + // Insertion fragments may carry sibling top-level elements; the decoder |
| 434 | + // must not enforce a single document element. |
| 435 | + {name: "multiple top-level elements", in: `<p>a</p><p>b</p>`}, |
| 436 | + {name: "escaped entities", in: `<p>A & B <tag> "q"</p>`}, |
| 437 | + {name: "CDATA with raw ampersand", in: `<p><![CDATA[a & b < c]]></p>`}, |
| 438 | + {name: "comment", in: `<!-- note --><shape/>`}, |
| 439 | + {name: "img placeholder attr", in: `<img src="@./local.png" width="100"/>`}, |
| 440 | + {name: "unicode text", in: `<p>项目汇报 🎯</p>`}, |
| 441 | + |
| 442 | + // Top CLI-path failure cause in engine logs: bare & in text. |
| 443 | + {name: "bare ampersand", in: `<p>Q & A</p>`, wantErr: "line 1"}, |
| 444 | + {name: "bare ampersand multiline", in: "<slide>\n<p>R&D</p>\n</slide>", wantErr: "line 2"}, |
| 445 | + {name: "unclosed tag", in: `<shape><content></shape>`, wantErr: "not well-formed"}, |
| 446 | + {name: "unquoted attribute", in: `<shape type=rect/>`, wantErr: "not well-formed"}, |
| 447 | + {name: "stray closing tag", in: `<p>hi</p></div>`, wantErr: "not well-formed"}, |
| 448 | + {name: "undefined entity", in: `<p>a b</p>`, wantErr: "not well-formed"}, |
| 449 | + |
| 450 | + // nodeserver rejects processing instructions ("?xml not provide the |
| 451 | + // implement"); reject the declaration locally regardless of position. |
| 452 | + {name: "xml declaration", in: `<?xml version="1.0"?><shape/>`, wantErr: "declaration"}, |
| 453 | + {name: "xml declaration with encoding", in: `<?xml version="1.0" encoding="UTF-8"?><slide/>`, wantErr: "declaration"}, |
| 454 | + {name: "uppercase xml declaration", in: `<?XML version="1.0"?><shape/>`, wantErr: "declaration"}, |
| 455 | + } |
| 456 | + |
| 457 | + for _, tt := range tests { |
| 458 | + t.Run(tt.name, func(t *testing.T) { |
| 459 | + t.Parallel() |
| 460 | + err := checkXMLWellFormed(tt.in) |
| 461 | + if tt.wantErr == "" { |
| 462 | + if err != nil { |
| 463 | + t.Fatalf("unexpected err: %v", err) |
| 464 | + } |
| 465 | + return |
| 466 | + } |
| 467 | + if err == nil { |
| 468 | + t.Fatalf("want error containing %q, got nil", tt.wantErr) |
| 469 | + } |
| 470 | + if !strings.Contains(err.Error(), tt.wantErr) { |
| 471 | + t.Fatalf("want error containing %q, got %q", tt.wantErr, err.Error()) |
| 472 | + } |
| 473 | + }) |
| 474 | + } |
| 475 | +} |
| 476 | + |
| 477 | +// TestRetryOnRateLimit verifies the 99991400 backoff helper: retryable |
| 478 | +// rate-limit errors are retried with backoff, anything else returns |
| 479 | +// immediately, and exhaustion surfaces the last rate-limit error. |
| 480 | +// |
| 481 | +// Not parallel: shrinks the package-level slidesRateLimitBaseDelay. |
| 482 | +func TestRetryOnRateLimit(t *testing.T) { |
| 483 | + restore := slidesRateLimitBaseDelay |
| 484 | + slidesRateLimitBaseDelay = time.Millisecond |
| 485 | + t.Cleanup(func() { slidesRateLimitBaseDelay = restore }) |
| 486 | + |
| 487 | + rateLimitErr := func() error { |
| 488 | + return errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithRetryable() |
| 489 | + } |
| 490 | + |
| 491 | + t.Run("success without retry", func(t *testing.T) { |
| 492 | + var errOut bytes.Buffer |
| 493 | + calls := 0 |
| 494 | + err := retryOnRateLimit(context.Background(), &errOut, func() error { |
| 495 | + calls++ |
| 496 | + return nil |
| 497 | + }) |
| 498 | + if err != nil || calls != 1 { |
| 499 | + t.Fatalf("err=%v calls=%d, want nil/1", err, calls) |
| 500 | + } |
| 501 | + if errOut.Len() != 0 { |
| 502 | + t.Fatalf("no retry message expected, got: %s", errOut.String()) |
| 503 | + } |
| 504 | + }) |
| 505 | + |
| 506 | + t.Run("succeeds after transient rate limit", func(t *testing.T) { |
| 507 | + var errOut bytes.Buffer |
| 508 | + calls := 0 |
| 509 | + err := retryOnRateLimit(context.Background(), &errOut, func() error { |
| 510 | + calls++ |
| 511 | + if calls <= 2 { |
| 512 | + return rateLimitErr() |
| 513 | + } |
| 514 | + return nil |
| 515 | + }) |
| 516 | + if err != nil || calls != 3 { |
| 517 | + t.Fatalf("err=%v calls=%d, want nil/3", err, calls) |
| 518 | + } |
| 519 | + if !strings.Contains(errOut.String(), "retrying") { |
| 520 | + t.Fatalf("expected retry announcement, got: %s", errOut.String()) |
| 521 | + } |
| 522 | + }) |
| 523 | + |
| 524 | + t.Run("exhaustion returns last rate-limit error", func(t *testing.T) { |
| 525 | + var errOut bytes.Buffer |
| 526 | + calls := 0 |
| 527 | + err := retryOnRateLimit(context.Background(), &errOut, func() error { |
| 528 | + calls++ |
| 529 | + return rateLimitErr() |
| 530 | + }) |
| 531 | + if err == nil || !isRateLimitedErr(err) { |
| 532 | + t.Fatalf("want rate-limit error after exhaustion, got: %v", err) |
| 533 | + } |
| 534 | + if calls != slidesRateLimitMaxRetries+1 { |
| 535 | + t.Fatalf("calls=%d, want %d", calls, slidesRateLimitMaxRetries+1) |
| 536 | + } |
| 537 | + }) |
| 538 | + |
| 539 | + t.Run("non-rate-limit error returns immediately", func(t *testing.T) { |
| 540 | + var errOut bytes.Buffer |
| 541 | + calls := 0 |
| 542 | + boom := errs.NewAPIError(errs.SubtypeNotFound, "not found") |
| 543 | + err := retryOnRateLimit(context.Background(), &errOut, func() error { |
| 544 | + calls++ |
| 545 | + return boom |
| 546 | + }) |
| 547 | + if !errors.Is(err, boom) || calls != 1 { |
| 548 | + t.Fatalf("err=%v calls=%d, want boom/1", err, calls) |
| 549 | + } |
| 550 | + }) |
| 551 | + |
| 552 | + t.Run("cancelled context aborts the backoff wait", func(t *testing.T) { |
| 553 | + var errOut bytes.Buffer |
| 554 | + ctx, cancel := context.WithCancel(context.Background()) |
| 555 | + cancel() |
| 556 | + err := retryOnRateLimit(ctx, &errOut, func() error { |
| 557 | + return rateLimitErr() |
| 558 | + }) |
| 559 | + if !errors.Is(err, context.Canceled) { |
| 560 | + t.Fatalf("want context.Canceled, got: %v", err) |
| 561 | + } |
| 562 | + }) |
| 563 | +} |
0 commit comments