@@ -50,9 +50,14 @@ func (c *Client) SetSessionTokenFunc(fn func(ctx context.Context) string) {
5050
5151// resolveAPIToken returns the API token to use for this request: the per-session
5252// token (if set by the user via configure_allure_token) or the server-wide token.
53+ // The function-pointer field is read under the mutex to avoid a data race with
54+ // SetSessionTokenFunc, which writes the field under the same mutex.
5355func (c * Client ) resolveAPIToken (ctx context.Context ) string {
54- if c .getSessionToken != nil {
55- if t := c .getSessionToken (ctx ); t != "" {
56+ c .mu .Lock ()
57+ fn := c .getSessionToken
58+ c .mu .Unlock ()
59+ if fn != nil {
60+ if t := fn (ctx ); t != "" {
5661 return t
5762 }
5863 }
@@ -65,14 +70,19 @@ func (c *Client) getJWTToken(ctx context.Context) (string, error) {
6570 return "" , fmt .Errorf ("no token configured - set ALLURE_TOKEN env var or use configure_allure_token tool" )
6671 }
6772
68- // Check cache without holding the lock during the HTTP call .
73+ // Fast path: check cache under the lock .
6974 c .mu .Lock ()
7075 if entry , ok := c .jwtCache [apiToken ]; ok && time .Now ().Before (entry .expiresAt ) {
7176 c .mu .Unlock ()
7277 return entry .jwt , nil
7378 }
7479 c .mu .Unlock ()
7580
81+ // Cache miss – fetch a new JWT outside the lock so we don't block other
82+ // goroutines. Two concurrent goroutines may both reach here for the same
83+ // apiToken; that results in a harmless double-fetch. After the fetch we
84+ // re-check the cache before writing so a race winner's entry is not
85+ // needlessly overwritten with a stale result.
7686 jwt , expiresIn , err := c .fetchJWT (ctx , apiToken )
7787 if err != nil {
7888 return "" , err
@@ -84,6 +94,11 @@ func (c *Client) getJWTToken(ctx context.Context) (string, error) {
8494 }
8595
8696 c .mu .Lock ()
97+ // Re-check: another goroutine may have already populated the cache.
98+ if existing , ok := c .jwtCache [apiToken ]; ok && time .Now ().Before (existing .expiresAt ) {
99+ c .mu .Unlock ()
100+ return existing .jwt , nil
101+ }
87102 c .jwtCache [apiToken ] = jwtEntry {jwt : jwt , expiresAt : expiresAt }
88103 c .mu .Unlock ()
89104
@@ -2662,8 +2677,11 @@ func (c *Client) RemoveTestCaseMembers(ctx context.Context, testCaseID int64, me
26622677 return nil
26632678}
26642679
2680+ // GetTestCaseExternalLinks returns the external URL links attached to a test case.
2681+ // The spec exposes these only via GET /api/testcase/{id}/overview (field "links").
2682+ // The /relation endpoint returns test-case-to-test-case relations (different schema).
26652683func (c * Client ) GetTestCaseExternalLinks (ctx context.Context , testCaseID int64 ) ([]ExternalLinkDto , error ) {
2666- httpReq , err := http .NewRequestWithContext (ctx , http .MethodGet , c .url (fmt .Sprintf ("/api/testcase/%d/relation " , testCaseID )), nil )
2684+ httpReq , err := http .NewRequestWithContext (ctx , http .MethodGet , c .url (fmt .Sprintf ("/api/testcase/%d/overview " , testCaseID )), nil )
26672685 if err != nil {
26682686 return nil , fmt .Errorf ("create request: %w" , err )
26692687 }
@@ -2682,62 +2700,53 @@ func (c *Client) GetTestCaseExternalLinks(ctx context.Context, testCaseID int64)
26822700 return nil , errFromResponse (resp )
26832701 }
26842702
2685- var result []ExternalLinkDto
2686- if err := json .NewDecoder (resp .Body ).Decode (& result ); err != nil {
2703+ // Decode only the "links" field to avoid pulling the full overview into memory.
2704+ var overview struct {
2705+ Links []ExternalLinkDto `json:"links"`
2706+ }
2707+ if err := json .NewDecoder (resp .Body ).Decode (& overview ); err != nil {
26872708 return nil , fmt .Errorf ("decode response: %w" , err )
26882709 }
26892710
2690- return result , nil
2711+ return overview . Links , nil
26912712}
26922713
2714+ // AddTestCaseExternalLink appends an external URL link to a test case.
2715+ // The spec provides no per-item POST endpoint; the only way to mutate links is
2716+ // PATCH /api/testcase/{id} with the complete desired "links" array (replace-all).
2717+ // We therefore fetch the current links first and patch with the appended list.
26932718func (c * Client ) AddTestCaseExternalLink (ctx context.Context , testCaseID int64 , link ExternalLinkDto ) error {
2694- body , err := json .Marshal (link )
2695- if err != nil {
2696- return fmt .Errorf ("marshal request: %w" , err )
2697- }
2698-
2699- httpReq , err := http .NewRequestWithContext (ctx , http .MethodPost , c .url (fmt .Sprintf ("/api/testcase/%d/relation" , testCaseID )), bytes .NewBuffer (body ))
2700- if err != nil {
2701- return fmt .Errorf ("create request: %w" , err )
2702- }
2703- if err := c .setAuthHeader (ctx , httpReq ); err != nil {
2704- return fmt .Errorf ("set auth: %w" , err )
2705- }
2706- httpReq .Header .Set ("Content-Type" , "application/json" )
2707-
2708- resp , err := c .httpClient .Do (httpReq )
2719+ current , err := c .GetTestCaseExternalLinks (ctx , testCaseID )
27092720 if err != nil {
2710- return fmt .Errorf ("http request : %w" , err )
2721+ return fmt .Errorf ("get current links : %w" , err )
27112722 }
2712- defer resp .Body .Close ()
2713-
2714- if resp .StatusCode != http .StatusOK && resp .StatusCode != http .StatusNoContent && resp .StatusCode != http .StatusCreated {
2715- return errFromResponse (resp )
2716- }
2717-
2718- return nil
2723+ updated := append (current , link )
2724+ return c .UpdateTestCase (ctx , testCaseID , UpdateTestCaseRequest {Links : updated })
27192725}
27202726
2721- func (c * Client ) DeleteTestCaseExternalLink (ctx context.Context , testCaseID int64 , relationID int64 ) error {
2722- httpReq , err := http .NewRequestWithContext (ctx , http .MethodDelete , c .url (fmt .Sprintf ("/api/testcase/%d/relation/%d" , testCaseID , relationID )), nil )
2727+ // DeleteTestCaseExternalLink removes the external link with the given URL from a test case.
2728+ // The spec has no DELETE endpoint for external links; removal is achieved by fetching
2729+ // the current list and PATCHing with the matching entry omitted.
2730+ func (c * Client ) DeleteTestCaseExternalLink (ctx context.Context , testCaseID int64 , linkURL string ) error {
2731+ current , err := c .GetTestCaseExternalLinks (ctx , testCaseID )
27232732 if err != nil {
2724- return fmt .Errorf ("create request: %w" , err )
2725- }
2726- if err := c .setAuthHeader (ctx , httpReq ); err != nil {
2727- return fmt .Errorf ("set auth: %w" , err )
2733+ return fmt .Errorf ("get current links: %w" , err )
27282734 }
27292735
2730- resp , err := c .httpClient .Do (httpReq )
2731- if err != nil {
2732- return fmt .Errorf ("http request: %w" , err )
2736+ remaining := make ([]ExternalLinkDto , 0 , len (current ))
2737+ found := false
2738+ for _ , l := range current {
2739+ if l .URL == linkURL {
2740+ found = true
2741+ continue
2742+ }
2743+ remaining = append (remaining , l )
27332744 }
2734- defer resp .Body .Close ()
2735-
2736- if resp .StatusCode != http .StatusOK && resp .StatusCode != http .StatusNoContent {
2737- return errFromResponse (resp )
2745+ if ! found {
2746+ return fmt .Errorf ("external link not found: %s" , linkURL )
27382747 }
27392748
2740- return nil
2749+ return c . UpdateTestCase ( ctx , testCaseID , UpdateTestCaseRequest { Links : remaining })
27412750}
27422751
27432752func (c * Client ) RestoreTestCase (ctx context.Context , testCaseID int64 ) error {
0 commit comments