github.com/letsencrypt/boulder@v0.20251208.0/test/zendeskfake/zendeskfake_test.go (about)

     1  package zendeskfake
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/http/httptest"
    11  	"net/url"
    12  	"strconv"
    13  	"strings"
    14  	"testing"
    15  )
    16  
    17  const (
    18  	apiTokenEmail = "tester@example.com"
    19  	apiToken      = "someToken"
    20  )
    21  
    22  func basicAuthHeader(email, token string) string {
    23  	raw := email + "/token:" + token
    24  	enc := base64.StdEncoding.EncodeToString([]byte(raw))
    25  	return "Basic " + enc
    26  }
    27  
    28  func startTestServer(t *testing.T) (*Server, *httptest.Server) {
    29  	t.Helper()
    30  
    31  	srv := NewServer(apiTokenEmail, apiToken, nil)
    32  	ts := httptest.NewServer(srv.Handler())
    33  	t.Cleanup(ts.Close)
    34  	return srv, ts
    35  }
    36  
    37  func startTestServerWithStore(t *testing.T, store *Store) (*Server, *httptest.Server) {
    38  	t.Helper()
    39  
    40  	srv := NewServer(apiTokenEmail, apiToken, store)
    41  	ts := httptest.NewServer(srv.Handler())
    42  	t.Cleanup(ts.Close)
    43  	return srv, ts
    44  }
    45  
    46  func doJSON(t *testing.T, method, urlStr, authHeader string, body []byte, setContentType bool) (*http.Response, []byte) {
    47  	t.Helper()
    48  
    49  	var reader io.Reader
    50  	if len(body) > 0 {
    51  		reader = bytes.NewReader(body)
    52  	}
    53  
    54  	req, err := http.NewRequest(method, urlStr, reader)
    55  	if err != nil {
    56  		t.Errorf("creating request %s %s failed: %s", method, urlStr, err)
    57  		return nil, nil
    58  	}
    59  	if authHeader != "" {
    60  		req.Header.Set("Authorization", authHeader)
    61  	}
    62  	req.Header.Set("Accept", "application/json")
    63  	if setContentType {
    64  		req.Header.Set("Content-Type", "application/json")
    65  	}
    66  
    67  	resp, err := http.DefaultClient.Do(req)
    68  	if err != nil {
    69  		t.Errorf("performing %s %s failed: %s", method, urlStr, err)
    70  		return nil, nil
    71  	}
    72  
    73  	respBody, err := io.ReadAll(resp.Body)
    74  	if err != nil {
    75  		t.Errorf("reading response body for %s %s failed: %s", method, urlStr, err)
    76  		err = resp.Body.Close()
    77  		if err != nil {
    78  			t.Errorf("closing response body for %s %s failed: %s", method, urlStr, err)
    79  		}
    80  		return resp, nil
    81  	}
    82  	err = resp.Body.Close()
    83  	if err != nil {
    84  		t.Errorf("closing response body for %s %s failed: %s", method, urlStr, err)
    85  	}
    86  	return resp, respBody
    87  }
    88  
    89  func postTicket(t *testing.T, baseURL string, body []byte) (*http.Response, []byte) {
    90  	t.Helper()
    91  
    92  	return doJSON(t, http.MethodPost, baseURL+TicketsJSONPath, basicAuthHeader(apiTokenEmail, apiToken), body, true)
    93  }
    94  
    95  func putUpdate(t *testing.T, baseURL string, id int64, body []byte) (*http.Response, []byte) {
    96  	t.Helper()
    97  
    98  	endpoint := fmt.Sprintf("%s%s%d.json", baseURL, TicketsPath, id)
    99  	return doJSON(t, http.MethodPut, endpoint, basicAuthHeader(apiTokenEmail, apiToken), body, true)
   100  }
   101  
   102  func getSearch(t *testing.T, baseURL, query string) (*http.Response, []byte) {
   103  	t.Helper()
   104  
   105  	v := url.Values{}
   106  	v.Set("query", query)
   107  	urlStr := baseURL + SearchJSONPath + "?" + v.Encode()
   108  	return doJSON(t, http.MethodGet, urlStr, basicAuthHeader(apiTokenEmail, apiToken), nil, false)
   109  }
   110  
   111  func createTicketAndReturnID(t *testing.T, baseURL string) int64 {
   112  	t.Helper()
   113  
   114  	payload := []byte(`{
   115  		"ticket": {
   116  			"requester": {"name":"R","email":"r@example.com"},
   117  			"subject": "S",
   118  			"comment": {"body":"B","public":true},
   119  			"custom_fields": []
   120  		}
   121  	}`)
   122  	resp, body := postTicket(t, baseURL, payload)
   123  	if resp == nil {
   124  		t.Errorf("unexpected nil response while creating ticket")
   125  		return 0
   126  	}
   127  	if resp.StatusCode != http.StatusCreated {
   128  		t.Errorf("create ticket: expected HTTP %d, got HTTP %d body=%s", http.StatusCreated, resp.StatusCode, string(body))
   129  	}
   130  
   131  	var out struct {
   132  		Ticket struct {
   133  			ID int64 `json:"id"`
   134  		} `json:"ticket"`
   135  	}
   136  	err := json.Unmarshal(body, &out)
   137  	if err != nil {
   138  		t.Errorf("unmarshalling create ticket response failed: %s", err)
   139  		return 0
   140  	}
   141  	return out.Ticket.ID
   142  }
   143  
   144  func TestAuthRequired(t *testing.T) {
   145  	t.Parallel()
   146  
   147  	_, ts := startTestServer(t)
   148  
   149  	resp, _ := doJSON(t, http.MethodGet, ts.URL+SearchJSONPath+"?query=type:ticket", "", nil, false)
   150  	if resp == nil {
   151  		t.Errorf("unexpected nil response for unauthorized request")
   152  		return
   153  	}
   154  	if resp.StatusCode != http.StatusUnauthorized {
   155  		t.Errorf("unauthorized request: expected HTTP %d, got HTTP %d", http.StatusUnauthorized, resp.StatusCode)
   156  	}
   157  }
   158  
   159  func TestAuthWrongCredentialsAllEndpoints(t *testing.T) {
   160  	t.Parallel()
   161  
   162  	_, ts := startTestServer(t)
   163  
   164  	validCreate := []byte(`{"ticket":{"requester":{"name":"n","email":"e@example.com"},"subject":"s","comment":{"body":"b","public":true},"custom_fields":[]}}`)
   165  	validUpdate := []byte(`{"ticket":{"comment":{"body":"x","public":false}}}`)
   166  
   167  	id := createTicketAndReturnID(t, ts.URL)
   168  
   169  	type ep struct {
   170  		name   string
   171  		method string
   172  		url    string
   173  		body   []byte
   174  	}
   175  	endpoints := []ep{
   176  		{"POST /tickets.json", http.MethodPost, ts.URL + TicketsJSONPath, validCreate},
   177  		{"GET  /search.json", http.MethodGet, ts.URL + SearchJSONPath + "?query=type:ticket", nil},
   178  		{"PUT  /tickets/{id}.json", http.MethodPut, ts.URL + TicketsPath + strconv.FormatInt(id, 10) + ".json", validUpdate},
   179  	}
   180  
   181  	for _, e := range endpoints {
   182  		t.Run(e.name+"/wrong-credentials", func(t *testing.T) {
   183  			resp, _ := doJSON(t, e.method, e.url, basicAuthHeader("wrong@example.com", "wrong"), e.body, true)
   184  			if resp == nil {
   185  				t.Errorf("%s wrong-credentials: unexpected nil response", e.name)
   186  				return
   187  			}
   188  			if resp.StatusCode != http.StatusUnauthorized {
   189  				t.Errorf("%s wrong-credentials: expected HTTP %d, got HTTP %d", e.name, http.StatusUnauthorized, resp.StatusCode)
   190  			}
   191  		})
   192  		t.Run(e.name+"/malformed-header", func(t *testing.T) {
   193  			resp, _ := doJSON(t, e.method, e.url, "Basic malformed-header", e.body, true)
   194  			if resp == nil {
   195  				t.Errorf("%s malformed-header: unexpected nil response", e.name)
   196  				return
   197  			}
   198  			if resp.StatusCode != http.StatusUnauthorized {
   199  				t.Errorf("%s malformed-header: expected HTTP %d, got HTTP %d", e.name, http.StatusUnauthorized, resp.StatusCode)
   200  			}
   201  		})
   202  	}
   203  }
   204  
   205  func TestCreateTicketSuccessAndStored(t *testing.T) {
   206  	t.Parallel()
   207  
   208  	srv, ts := startTestServer(t)
   209  
   210  	payload := []byte(`{
   211  		"ticket": {
   212  			"requester": {"name":"Alice","email":"alice@example.com"},
   213  			"subject": "Subject A",
   214  			"comment": {"body":"Hello world","public":true},
   215  			"custom_fields": [
   216  				{"id": 111, "value":"pending"},
   217  				{"id": 222, "value":"Acme"}
   218  			]
   219  		}
   220  	}`)
   221  
   222  	resp, body := postTicket(t, ts.URL, payload)
   223  	if resp == nil {
   224  		t.Errorf("create ticket: unexpected nil response")
   225  		return
   226  	}
   227  	if resp.StatusCode != http.StatusCreated {
   228  		t.Errorf("create ticket: expected HTTP %d, got HTTP %d body=%s", http.StatusCreated, resp.StatusCode, string(body))
   229  	}
   230  
   231  	var res struct {
   232  		Ticket struct {
   233  			ID int64 `json:"id"`
   234  		} `json:"ticket"`
   235  	}
   236  	err := json.Unmarshal(body, &res)
   237  	if err != nil {
   238  		t.Errorf("unmarshal create response failed: %s", err)
   239  		return
   240  	}
   241  	if res.Ticket.ID == 0 {
   242  		t.Errorf("create ticket: expected non-zero id")
   243  		return
   244  	}
   245  
   246  	got, ok := srv.GetTicket(res.Ticket.ID)
   247  	if !ok {
   248  		t.Errorf("ticket id %d not found in store", res.Ticket.ID)
   249  		return
   250  	}
   251  	if got.Status != "new" {
   252  		t.Errorf("ticket default status mismatch: got %q, want %q", got.Status, "new")
   253  	}
   254  	if got.Subject != "Subject A" {
   255  		t.Errorf("ticket subject mismatch: got %q, want %q", got.Subject, "Subject A")
   256  	}
   257  	if len(got.Comments) != 1 || got.Comments[0].Body != "Hello world" || !got.Comments[0].Public {
   258  		t.Errorf("ticket comment stored incorrectly: %#v (want one public 'Hello world' comment)", got.Comments)
   259  	}
   260  	if got.CustomFields[111] != "pending" || got.CustomFields[222] != "Acme" {
   261  		t.Errorf("ticket custom fields stored incorrectly: %#v (want 111=%q 222=%q)", got.CustomFields, "pending", "Acme")
   262  	}
   263  }
   264  
   265  func TestCreateTicketUnhappyPaths(t *testing.T) {
   266  	t.Parallel()
   267  
   268  	_, ts := startTestServer(t)
   269  
   270  	cases := []struct {
   271  		name       string
   272  		body       []byte
   273  		wantStatus int
   274  	}{
   275  		{
   276  			name:       "bad json",
   277  			body:       []byte(`{"ticket": { "requester": {"name":"A","email":"a@example.com"},`),
   278  			wantStatus: http.StatusBadRequest,
   279  		},
   280  		{
   281  			name: "missing subject",
   282  			body: []byte(`{
   283  				"ticket": {
   284  					"requester": {"name":"Bob","email":"bob@example.com"},
   285  					"comment": {"body":"Hi","public":true}
   286  				}
   287  			}`),
   288  			wantStatus: http.StatusUnprocessableEntity,
   289  		},
   290  		{
   291  			name: "missing email",
   292  			body: []byte(`{
   293  				"ticket": {
   294  					"requester": {"name":"NoEmail"},
   295  					"subject": "S",
   296  					"comment": {"body":"B","public":true}
   297  				}
   298  			}`),
   299  			wantStatus: http.StatusUnprocessableEntity,
   300  		},
   301  		{
   302  			name: "missing comment body",
   303  			body: []byte(`{
   304  				"ticket": {
   305  					"requester": {"name":"N","email":"n@example.com"},
   306  					"subject": "S",
   307  					"comment": {"public":true}
   308  				}
   309  			}`),
   310  			wantStatus: http.StatusUnprocessableEntity,
   311  		},
   312  		{
   313  			name:       "empty body",
   314  			body:       nil,
   315  			wantStatus: http.StatusBadRequest,
   316  		},
   317  	}
   318  
   319  	for _, tc := range cases {
   320  		t.Run(tc.name, func(t *testing.T) {
   321  			t.Parallel()
   322  
   323  			resp, body := postTicket(t, ts.URL, tc.body)
   324  			if resp == nil {
   325  				t.Errorf("create ticket (%s): unexpected nil response", tc.name)
   326  				return
   327  			}
   328  			if resp.StatusCode != tc.wantStatus {
   329  				t.Errorf("create ticket (%s): expected HTTP %d, got HTTP %d body=%s", tc.name, tc.wantStatus, resp.StatusCode, string(body))
   330  			}
   331  		})
   332  	}
   333  }
   334  
   335  func TestUpdateTicketVariants(t *testing.T) {
   336  	t.Parallel()
   337  
   338  	type tc struct {
   339  		name          string
   340  		payload       []byte
   341  		wantHTTP      int
   342  		expectStatus  string
   343  		expectComment *comment
   344  	}
   345  
   346  	cases := []tc{
   347  		{
   348  			name:          "adds comment only",
   349  			payload:       []byte(`{"ticket":{"comment":{"body":"Follow-up","public":false}}}`),
   350  			wantHTTP:      http.StatusOK,
   351  			expectStatus:  "new",
   352  			expectComment: &comment{Body: "Follow-up", Public: false},
   353  		},
   354  		{
   355  			name:         "status only (open)",
   356  			payload:      []byte(`{"ticket":{"status":"open"}}`),
   357  			wantHTTP:     http.StatusOK,
   358  			expectStatus: "open",
   359  		},
   360  		{
   361  			name:          "status with comment (solved, public)",
   362  			payload:       []byte(`{"ticket":{"status":"solved","comment":{"body":"Resolved","public":true}}}`),
   363  			wantHTTP:      http.StatusOK,
   364  			expectStatus:  "solved",
   365  			expectComment: &comment{Body: "Resolved", Public: true},
   366  		},
   367  		{
   368  			name:         "invalid status",
   369  			payload:      []byte(`{"ticket":{"status":"bogus"}}`),
   370  			wantHTTP:     http.StatusUnprocessableEntity,
   371  			expectStatus: "new",
   372  		},
   373  	}
   374  
   375  	for _, tc := range cases {
   376  		t.Run(tc.name, func(t *testing.T) {
   377  			t.Parallel()
   378  
   379  			srv, ts := startTestServer(t)
   380  			id := createTicketAndReturnID(t, ts.URL)
   381  
   382  			resp, body := putUpdate(t, ts.URL, id, tc.payload)
   383  			if resp == nil {
   384  				t.Fatalf("unexpected nil response from putUpdate %s", tc.name)
   385  			}
   386  			if resp.StatusCode != tc.wantHTTP {
   387  				t.Errorf("expected HTTP %d, got %d body=%s", tc.wantHTTP, resp.StatusCode, string(body))
   388  			}
   389  
   390  			got, ok := srv.GetTicket(id)
   391  			if !ok {
   392  				t.Errorf("id %d not found in store after update %s", id, tc.name)
   393  			}
   394  
   395  			if got.Status != tc.expectStatus {
   396  				t.Errorf("status mismatch: got %q, expected %q", got.Status, tc.expectStatus)
   397  			}
   398  			if tc.expectComment != nil {
   399  				found := false
   400  				for _, c := range got.Comments {
   401  					if c.Body == tc.expectComment.Body && c.Public == tc.expectComment.Public {
   402  						found = true
   403  						break
   404  					}
   405  				}
   406  				if !found {
   407  					t.Errorf("expected comment %q public=%t not found in ticket comments: %#v", tc.expectComment.Body, tc.expectComment.Public, got.Comments)
   408  				}
   409  			} else if len(got.Comments) > 1 {
   410  				t.Errorf("expected no additional comments, got %d comments: %#v", len(got.Comments), got.Comments)
   411  			}
   412  		})
   413  	}
   414  }
   415  
   416  func TestUpdateTicketUnhappyPaths(t *testing.T) {
   417  	t.Parallel()
   418  
   419  	_, ts := startTestServer(t)
   420  
   421  	validID := createTicketAndReturnID(t, ts.URL)
   422  
   423  	type tc struct {
   424  		name       string
   425  		method     string
   426  		path       string
   427  		body       []byte
   428  		wantStatus int
   429  	}
   430  	tests := []tc{
   431  		{
   432  			name:       "bad id path (non-numeric)",
   433  			method:     http.MethodPut,
   434  			path:       TicketsPath + "abc.json",
   435  			body:       []byte(`{"ticket":{"comment":{"body":"x","public":false}}}`),
   436  			wantStatus: http.StatusNotFound,
   437  		},
   438  		{
   439  			name:       "missing id segment",
   440  			method:     http.MethodPut,
   441  			path:       TicketsPath + ".json",
   442  			body:       []byte(`{"ticket":{"comment":{"body":"x","public":true}}}`),
   443  			wantStatus: http.StatusNotFound,
   444  		},
   445  		{
   446  			name:       "unknown id",
   447  			method:     http.MethodPut,
   448  			path:       TicketsPath + "999999.json",
   449  			body:       []byte(`{"ticket":{"comment":{"body":"x","public":true}}}`),
   450  			wantStatus: http.StatusNotFound,
   451  		},
   452  		{
   453  			name:       "bad json",
   454  			method:     http.MethodPut,
   455  			path:       TicketsPath + strconv.FormatInt(validID, 10) + ".json",
   456  			body:       []byte(`{"ticket": {"comment":`),
   457  			wantStatus: http.StatusBadRequest,
   458  		},
   459  		{
   460  			name:       "missing comment body",
   461  			method:     http.MethodPut,
   462  			path:       TicketsPath + strconv.FormatInt(validID, 10) + ".json",
   463  			body:       []byte(`{"ticket":{"comment":{"public":true}}}`),
   464  			wantStatus: http.StatusUnprocessableEntity,
   465  		},
   466  	}
   467  
   468  	for _, tt := range tests {
   469  		t.Run(tt.name, func(t *testing.T) {
   470  			t.Parallel()
   471  
   472  			resp, body := doJSON(t, tt.method, ts.URL+tt.path, basicAuthHeader(apiTokenEmail, apiToken), tt.body, true)
   473  			if resp == nil {
   474  				t.Errorf("%s: unexpected nil response", tt.name)
   475  				return
   476  			}
   477  			if resp.StatusCode != tt.wantStatus {
   478  				t.Errorf("%s: expected HTTP %d, got HTTP %d body=%s", tt.name, tt.wantStatus, resp.StatusCode, string(body))
   479  			}
   480  
   481  			if tt.wantStatus == http.StatusNotFound && (strings.Contains(tt.name, "bad id path") || strings.Contains(tt.name, "missing id")) {
   482  				ct := resp.Header.Get("Content-Type")
   483  				if !strings.HasPrefix(ct, "application/json") {
   484  					t.Errorf("%s: expected Content-Type application/json, got %q", tt.name, ct)
   485  				}
   486  				var payload struct {
   487  					Error       string `json:"error"`
   488  					Description string `json:"description"`
   489  				}
   490  				err := json.Unmarshal(body, &payload)
   491  				if err != nil {
   492  					t.Errorf("%s: unmarshal 404 payload failed: %s (body=%q)", tt.name, err, string(body))
   493  				}
   494  				if payload.Error != "RecordNotFound" || payload.Description != "Not found" {
   495  					t.Errorf("%s: unexpected 404 payload: %#v", tt.name, payload)
   496  				}
   497  			}
   498  		})
   499  	}
   500  }
   501  
   502  func TestSearchNoTypeTicketReturnsEmpty(t *testing.T) {
   503  	t.Parallel()
   504  
   505  	_, ts := startTestServer(t)
   506  
   507  	resp, body := getSearch(t, ts.URL, "custom_field_1:foo")
   508  	if resp == nil {
   509  		t.Errorf("search without type: unexpected nil response")
   510  		return
   511  	}
   512  	if resp.StatusCode != http.StatusOK {
   513  		t.Errorf("search without type: expected HTTP %d, got HTTP %d", http.StatusOK, resp.StatusCode)
   514  	}
   515  
   516  	var out struct {
   517  		Results []any `json:"results"`
   518  		Next    any   `json:"next_page"`
   519  	}
   520  	err := json.Unmarshal(body, &out)
   521  	if err != nil {
   522  		t.Errorf("search without type: unmarshal response failed: %s", err)
   523  	}
   524  	if len(out.Results) != 0 {
   525  		t.Errorf("search without type: expected 0 results, got %d", len(out.Results))
   526  	}
   527  }
   528  
   529  func TestSearchByCustomFieldsQuotedAndUnquoted(t *testing.T) {
   530  	t.Parallel()
   531  
   532  	_, ts := startTestServer(t)
   533  
   534  	payload1 := []byte(`{
   535  		"ticket": {
   536  			"requester": {"name":"A","email":"a@example.com"},
   537  			"subject": "S1",
   538  			"comment": {"body":"B1","public":true},
   539  			"custom_fields": [
   540  				{"id": 111, "value": "pending"},
   541  				{"id": 222, "value": "Acme"}
   542  			]
   543  		}
   544  	}`)
   545  	resp, body := postTicket(t, ts.URL, payload1)
   546  	if resp == nil {
   547  		t.Errorf("create ticket 1: unexpected nil response")
   548  		return
   549  	}
   550  	if resp.StatusCode != http.StatusCreated {
   551  		t.Errorf("create ticket 1: expected HTTP %d, got HTTP %d body=%s", http.StatusCreated, resp.StatusCode, string(body))
   552  	}
   553  
   554  	payload2 := []byte(`{
   555  		"ticket": {
   556  			"requester": {"name":"B","email":"b@example.com"},
   557  			"subject": "S2",
   558  			"comment": {"body":"B2","public":true},
   559  			"custom_fields": [
   560  				{"id": 111, "value": "pending review"},
   561  				{"id": 222, "value": "Acme"}
   562  			]
   563  		}
   564  	}`)
   565  	resp, body = postTicket(t, ts.URL, payload2)
   566  	if resp == nil {
   567  		t.Errorf("create ticket 2: unexpected nil response")
   568  		return
   569  	}
   570  	if resp.StatusCode != http.StatusCreated {
   571  		t.Errorf("create ticket 2: expected HTTP %d, got HTTP %d body=%s", http.StatusCreated, resp.StatusCode, string(body))
   572  	}
   573  
   574  	resp, body = getSearch(t, ts.URL, `type:ticket custom_field_111:pending`)
   575  	if resp == nil {
   576  		t.Errorf("search unquoted: unexpected nil response")
   577  		return
   578  	}
   579  	if resp.StatusCode != http.StatusOK {
   580  		t.Errorf("search unquoted: expected HTTP %d, got HTTP %d", http.StatusOK, resp.StatusCode)
   581  	}
   582  	var res1 struct{ Results []any }
   583  	err := json.Unmarshal(body, &res1)
   584  	if err != nil {
   585  		t.Errorf("search unquoted: unmarshal failed: %s", err)
   586  	}
   587  	if len(res1.Results) != 1 {
   588  		t.Errorf("search unquoted: expected 1 result, got %d", len(res1.Results))
   589  	}
   590  
   591  	resp, body = getSearch(t, ts.URL, `type:ticket custom_field_111:"pending review"`)
   592  	if resp == nil {
   593  		t.Errorf("search quoted: unexpected nil response")
   594  		return
   595  	}
   596  	if resp.StatusCode != http.StatusOK {
   597  		t.Errorf("search quoted: expected HTTP %d, got HTTP %d", http.StatusOK, resp.StatusCode)
   598  	}
   599  	var res2 struct{ Results []any }
   600  	err = json.Unmarshal(body, &res2)
   601  	if err != nil {
   602  		t.Errorf("search quoted: unmarshal failed: %s", err)
   603  	}
   604  	if len(res2.Results) != 1 {
   605  		t.Errorf("search quoted: expected 1 result, got %d", len(res2.Results))
   606  	}
   607  }
   608  
   609  func TestSearchNewestFirstOrder(t *testing.T) {
   610  	t.Parallel()
   611  
   612  	_, ts := startTestServer(t)
   613  
   614  	for i := 1; i <= 3; i++ {
   615  		payload := fmt.Sprintf(`{
   616  			"ticket": {
   617  				"requester": {"name":"U%d","email":"u%d@example.com"},
   618  				"subject": "S%d",
   619  				"comment": {"body":"B%d","public":true},
   620  				"custom_fields": [
   621  					{"id": 999, "value": "x"}
   622  				]
   623  			}
   624  		}`, i, i, i, i)
   625  
   626  		resp, body := postTicket(t, ts.URL, []byte(payload))
   627  		if resp == nil {
   628  			t.Errorf("create ticket %d: unexpected nil response", i)
   629  			return
   630  		}
   631  		if resp.StatusCode != http.StatusCreated {
   632  			t.Errorf("create ticket %d: expected HTTP %d, got HTTP %d body=%s", i, http.StatusCreated, resp.StatusCode, string(body))
   633  			return
   634  		}
   635  	}
   636  
   637  	type item struct {
   638  		ID int64 `json:"id"`
   639  	}
   640  	type page struct {
   641  		Results []item  `json:"results"`
   642  		Next    *string `json:"next_page"`
   643  	}
   644  
   645  	var all []item
   646  
   647  	resp, body := getSearch(t, ts.URL, `type:ticket custom_field_999:x`)
   648  	if resp == nil {
   649  		t.Errorf("initial search: unexpected nil response")
   650  		return
   651  	}
   652  	if resp.StatusCode != http.StatusOK {
   653  		t.Errorf("initial search: expected HTTP %d, got HTTP %d", http.StatusOK, resp.StatusCode)
   654  		return
   655  	}
   656  	var pg page
   657  	err := json.Unmarshal(body, &pg)
   658  	if err != nil {
   659  		t.Errorf("initial search: unmarshal failed: %s", err)
   660  		return
   661  	}
   662  	all = append(all, pg.Results...)
   663  
   664  	next := pg.Next
   665  	for next != nil && *next != "" {
   666  		nextURL := *next
   667  		if strings.HasPrefix(nextURL, "/") {
   668  			nextURL = ts.URL + nextURL
   669  		}
   670  		resp, body = doJSON(t, http.MethodGet, nextURL, basicAuthHeader(apiTokenEmail, apiToken), nil, false)
   671  		if resp == nil {
   672  			t.Errorf("paginated search: unexpected nil response on next_page")
   673  			return
   674  		}
   675  		if resp.StatusCode != http.StatusOK {
   676  			t.Errorf("paginated search: expected HTTP %d on next_page, got HTTP %d", http.StatusOK, resp.StatusCode)
   677  			return
   678  		}
   679  		var np page
   680  		err = json.Unmarshal(body, &np)
   681  		if err != nil {
   682  			t.Errorf("paginated search: unmarshal next_page failed: %s", err)
   683  			return
   684  		}
   685  		all = append(all, np.Results...)
   686  		next = np.Next
   687  	}
   688  
   689  	if len(all) != 3 {
   690  		t.Errorf("expected 3 results, got %d", len(all))
   691  		return
   692  	}
   693  	if !(all[0].ID > all[1].ID && all[1].ID > all[2].ID) {
   694  		t.Errorf("order incorrect (want strictly descending IDs): %#v", all)
   695  	}
   696  }
   697  
   698  func TestCapacityEviction(t *testing.T) {
   699  	t.Parallel()
   700  
   701  	store := NewStore(2)
   702  	srv, ts := startTestServerWithStore(t, store)
   703  
   704  	for i := 1; i <= 3; i++ {
   705  		payload := fmt.Sprintf(`{
   706  			"ticket": {
   707  				"requester": {"name":"E%d","email":"e%d@example.com"},
   708  				"subject": "Sub%d",
   709  				"comment": {"body":"C%d","public":true},
   710  				"custom_fields": [
   711  					{"id": 111, "value": "v%d"}
   712  				]
   713  			}
   714  		}`, i, i, i, i, i)
   715  
   716  		resp, body := postTicket(t, ts.URL, []byte(payload))
   717  		if resp == nil {
   718  			t.Errorf("unexpected nil response creating ticket %d", i)
   719  			return
   720  		}
   721  		if resp.StatusCode != http.StatusCreated {
   722  			t.Errorf("create ticket %d expected HTTP %d, got HTTP %d body=%s", i, http.StatusCreated, resp.StatusCode, string(body))
   723  		}
   724  	}
   725  
   726  	_, ok := srv.GetTicket(1)
   727  	if ok {
   728  		t.Errorf("expected ticket 1 to be evicted")
   729  	}
   730  	_, ok = srv.GetTicket(2)
   731  	if !ok {
   732  		t.Errorf("expected ticket 2 to remain")
   733  	}
   734  	_, ok = srv.GetTicket(3)
   735  	if !ok {
   736  		t.Errorf("expected ticket 3 to remain")
   737  	}
   738  }
   739  
   740  func TestSearchInvalidCustomFieldID(t *testing.T) {
   741  	t.Parallel()
   742  
   743  	_, ts := startTestServer(t)
   744  	_ = createTicketAndReturnID(t, ts.URL)
   745  
   746  	resp, body := getSearch(t, ts.URL, `type:ticket custom_field_abc:foo`)
   747  	if resp == nil {
   748  		t.Errorf("invalid custom field id search: unexpected nil response")
   749  		return
   750  	}
   751  	if resp.StatusCode != http.StatusBadRequest {
   752  		t.Errorf("invalid custom field id search: expected HTTP %d, got HTTP %d body=%s", http.StatusBadRequest, resp.StatusCode, string(body))
   753  	}
   754  }
   755  
   756  func TestSearchMissingQueryParam(t *testing.T) {
   757  	t.Parallel()
   758  
   759  	_, ts := startTestServer(t)
   760  
   761  	resp, body := doJSON(t, http.MethodGet, ts.URL+SearchJSONPath, basicAuthHeader(apiTokenEmail, apiToken), nil, false)
   762  	if resp == nil {
   763  		t.Errorf("missing query param search: unexpected nil response")
   764  		return
   765  	}
   766  	if resp.StatusCode != http.StatusOK {
   767  		t.Errorf("missing query param search: expected HTTP %d, got HTTP %d", http.StatusOK, resp.StatusCode)
   768  	}
   769  
   770  	var out struct {
   771  		Results []any `json:"results"`
   772  		Next    any   `json:"next_page"`
   773  	}
   774  	err := json.Unmarshal(body, &out)
   775  	if err != nil {
   776  		t.Errorf("missing query param search: unmarshal failed: %s", err)
   777  	}
   778  	if len(out.Results) != 0 {
   779  		t.Errorf("missing query param search: expected 0 results, got %d", len(out.Results))
   780  	}
   781  }