2
0

isolation_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package httpserver
  2. import (
  3. "bytes"
  4. "net/http"
  5. "net/http/httptest"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/goccy/go-json"
  10. "github.com/danfragoso/pizzasql-next/pkg/storage"
  11. "github.com/danfragoso/pizzasql-next/pkg/testkv"
  12. )
  13. func newTestDBServer(t *testing.T) *Server {
  14. t.Helper()
  15. kv := testkv.New(t)
  16. pool := kv.Pool(8)
  17. t.Cleanup(func() { pool.Close() })
  18. dm := storage.NewDatabaseManager(pool, &storage.DatabaseManagerConfig{
  19. DefaultDatabase: "testdb",
  20. AutoCreate: true,
  21. })
  22. config := DefaultConfig()
  23. config.EnableAuth = false
  24. return NewWithDatabaseManager(config, dm)
  25. }
  26. func postQuery(t *testing.T, s *Server, sql string) *httptest.ResponseRecorder {
  27. t.Helper()
  28. body, _ := json.Marshal(QueryRequest{SQL: sql})
  29. r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
  30. w := httptest.NewRecorder()
  31. s.handleQuery(w, r)
  32. return w
  33. }
  34. func postTransactionQuery(t *testing.T, s *Server, txID, sql string) *httptest.ResponseRecorder {
  35. t.Helper()
  36. body, _ := json.Marshal(QueryRequest{SQL: sql, TransactionID: txID})
  37. r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
  38. w := httptest.NewRecorder()
  39. s.handleQuery(w, r)
  40. return w
  41. }
  42. func beginTransaction(t *testing.T, s *Server, database string) string {
  43. t.Helper()
  44. r := httptest.NewRequest(http.MethodPost, "/transaction/begin", nil)
  45. r.Header.Set("X-Database", database)
  46. w := httptest.NewRecorder()
  47. s.handleTransactionBegin(w, r)
  48. if w.Code != http.StatusOK {
  49. t.Fatalf("begin status %d: %s", w.Code, w.Body.String())
  50. }
  51. var resp map[string]interface{}
  52. if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
  53. t.Fatal(err)
  54. }
  55. return resp["transactionId"].(string)
  56. }
  57. func postExecute(t *testing.T, s *Server, req ExecuteRequest) *httptest.ResponseRecorder {
  58. t.Helper()
  59. body, _ := json.Marshal(req)
  60. r := httptest.NewRequest(http.MethodPost, "/execute", bytes.NewReader(body))
  61. w := httptest.NewRecorder()
  62. s.handleExecute(w, r)
  63. return w
  64. }
  65. // TestHTTPTransactionIsolation verifies that concurrent HTTP requests do not
  66. // share mutable executor state: two concurrent transactional batch executes
  67. // both commit their own rows without cross-talk.
  68. func TestHTTPTransactionIsolation(t *testing.T) {
  69. s := newTestDBServer(t)
  70. w := postQuery(t, s, "CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)")
  71. if w.Code != http.StatusOK {
  72. t.Fatalf("create table: %d %s", w.Code, w.Body.String())
  73. }
  74. const n = 20
  75. var wg sync.WaitGroup
  76. codes := make(chan int, n)
  77. for i := 0; i < n; i++ {
  78. wg.Add(1)
  79. go func(i int) {
  80. defer wg.Done()
  81. req := ExecuteRequest{
  82. Transaction: true,
  83. Statements: []QueryRequest{
  84. {SQL: "INSERT INTO items VALUES (" + itoa(i) + ", 'v')"},
  85. },
  86. }
  87. codes <- postExecute(t, s, req).Code
  88. }(i)
  89. }
  90. wg.Wait()
  91. close(codes)
  92. for code := range codes {
  93. if code != http.StatusOK {
  94. t.Fatalf("execute transaction returned status %d", code)
  95. }
  96. }
  97. res := postQuery(t, s, "SELECT COUNT(*) FROM items")
  98. var resp QueryResponse
  99. if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
  100. t.Fatal(err)
  101. }
  102. if len(resp.Rows) != 1 || resp.Rows[0][0] != float64(n) {
  103. t.Fatalf("expected %d rows, got %v", n, resp.Rows)
  104. }
  105. }
  106. // TestHTTPTransactionEndpointsIsolation verifies that the session-based
  107. // BEGIN/COMMIT endpoints keep separate transactions isolated and single-use.
  108. func TestHTTPTransactionEndpointsIsolation(t *testing.T) {
  109. s := newTestDBServer(t)
  110. if w := postQuery(t, s, "CREATE TABLE t (id INTEGER PRIMARY KEY)"); w.Code != http.StatusOK {
  111. t.Fatalf("create table: %d", w.Code)
  112. }
  113. begin := func() string {
  114. r := httptest.NewRequest(http.MethodPost, "/transaction/begin", nil)
  115. w := httptest.NewRecorder()
  116. s.handleTransactionBegin(w, r)
  117. if w.Code != http.StatusOK {
  118. t.Fatalf("begin status %d", w.Code)
  119. }
  120. var resp map[string]interface{}
  121. json.NewDecoder(w.Body).Decode(&resp)
  122. return resp["transactionId"].(string)
  123. }
  124. commit := func(txID string) int {
  125. body, _ := json.Marshal(TransactionRequest{TransactionID: txID})
  126. r := httptest.NewRequest(http.MethodPost, "/transaction/commit", bytes.NewReader(body))
  127. w := httptest.NewRecorder()
  128. s.handleTransactionCommit(w, r)
  129. return w.Code
  130. }
  131. tx1 := begin()
  132. tx2 := begin()
  133. if tx1 == tx2 {
  134. t.Fatal("expected distinct transaction IDs")
  135. }
  136. if w := postTransactionQuery(t, s, tx1, "INSERT INTO t VALUES (1)"); w.Code != http.StatusOK {
  137. t.Fatalf("tx1 insert status %d: %s", w.Code, w.Body.String())
  138. }
  139. if w := postTransactionQuery(t, s, tx2, "INSERT INTO t VALUES (2)"); w.Code != http.StatusOK {
  140. t.Fatalf("tx2 insert status %d: %s", w.Code, w.Body.String())
  141. }
  142. if w := postQuery(t, s, "SELECT COUNT(*) FROM t"); w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte("[[0]]")) {
  143. t.Fatalf("uncommitted rows became visible: %d %s", w.Code, w.Body.String())
  144. }
  145. // Committing one transaction must not affect the other.
  146. if code := commit(tx1); code != http.StatusOK {
  147. t.Fatalf("commit tx1 status %d", code)
  148. }
  149. // A second commit of the same transaction fails (single-use).
  150. if code := commit(tx1); code != http.StatusNotFound {
  151. t.Fatalf("expected single-use transaction, got status %d", code)
  152. }
  153. // The other transaction is still usable.
  154. if code := commit(tx2); code != http.StatusOK {
  155. t.Fatalf("commit tx2 status %d", code)
  156. }
  157. }
  158. func TestHTTPTransactionIDsAreDatabaseScopedAndExpire(t *testing.T) {
  159. s := newTestDBServer(t)
  160. txID := beginTransaction(t, s, "tenant-a")
  161. body, _ := json.Marshal(QueryRequest{SQL: "SELECT 1", TransactionID: txID})
  162. r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
  163. r.Header.Set("X-Database", "tenant-b")
  164. w := httptest.NewRecorder()
  165. s.handleQuery(w, r)
  166. if w.Code != http.StatusNotFound {
  167. t.Fatalf("cross-database transaction returned %d, want 404", w.Code)
  168. }
  169. s.transactionExecutorsMu.Lock()
  170. s.transactionExecutors[txID].expiresAt = time.Now().Add(-time.Second)
  171. s.transactionExecutorsMu.Unlock()
  172. body, _ = json.Marshal(QueryRequest{SQL: "SELECT 1", TransactionID: txID})
  173. r = httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
  174. r.Header.Set("X-Database", "tenant-a")
  175. w = httptest.NewRecorder()
  176. s.handleQuery(w, r)
  177. if w.Code != http.StatusNotFound {
  178. t.Fatalf("expired transaction returned %d, want 404", w.Code)
  179. }
  180. }
  181. func itoa(i int) string {
  182. if i == 0 {
  183. return "0"
  184. }
  185. var b []byte
  186. for i > 0 {
  187. b = append([]byte{byte('0' + i%10)}, b...)
  188. i /= 10
  189. }
  190. return string(b)
  191. }