tep 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,146 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Proxy streaming (chunk 6.2, #81). Self-calling scheduled-server
4
+ # shape like test_http.rb / test_proxy.rb: the app boots an SSE
5
+ # upstream producer (/sse_upstream) and proxy routes that forward to
6
+ # 127.0.0.1:<own-port> with stream_request? => true, exercising
7
+ # on_stream_chunk per event + on_stream_end once at the end.
8
+ #
9
+ # Requires the scheduled server (proxy streaming parks on io_wait,
10
+ # same constraint as WebSocket). workers=1 so the upstream-producer
11
+ # fiber and the proxy-consumer fiber cooperate on one worker.
12
+ class TestProxyStreaming < TepTest
13
+ app_source <<~RB
14
+ require 'sinatra'
15
+
16
+ set :scheduler, :scheduled
17
+ set :workers, 1
18
+
19
+ # ---- upstream: emits three SSE events then closes ----
20
+ class SseTicks < Tep::Streamer
21
+ def pump(out)
22
+ out.write("data: a\\n\\n")
23
+ out.write("data: b\\n\\n")
24
+ out.write("data: c\\n\\n")
25
+ end
26
+ end
27
+
28
+ get '/sse_upstream' do
29
+ res.headers["Content-Type"] = "text/event-stream"
30
+ stream SseTicks.new
31
+ end
32
+
33
+ # ---- proxy subclasses (streaming hooks) ----
34
+
35
+ # Plain pass-through.
36
+ class PassProxy < Tep::Proxy
37
+ def rewrite_path(path)
38
+ "/sse_upstream"
39
+ end
40
+ def stream_request?(req)
41
+ true
42
+ end
43
+ end
44
+
45
+ # Finalizer: emit a closing event carrying the framework's
46
+ # chunk_count, proving on_stream_end fires once with live stats.
47
+ class FinalizeProxy < Tep::Proxy
48
+ def rewrite_path(path)
49
+ "/sse_upstream"
50
+ end
51
+ def stream_request?(req)
52
+ true
53
+ end
54
+ def on_stream_end(req, out, stats)
55
+ out.write("data: count=" + stats.chunk_count.to_s + "\\n\\n")
56
+ 0
57
+ end
58
+ end
59
+
60
+ # Transform: prefix every event, proving on_stream_chunk can
61
+ # rewrite bytes on the way through.
62
+ class PrefixProxy < Tep::Proxy
63
+ def rewrite_path(path)
64
+ "/sse_upstream"
65
+ end
66
+ def stream_request?(req)
67
+ true
68
+ end
69
+ def on_stream_chunk(chunk, out, stats)
70
+ out.write("x-" + chunk.chunk_text)
71
+ 0
72
+ end
73
+ end
74
+
75
+ # Drop: forward only events whose payload contains "b".
76
+ class DropProxy < Tep::Proxy
77
+ def rewrite_path(path)
78
+ "/sse_upstream"
79
+ end
80
+ def stream_request?(req)
81
+ true
82
+ end
83
+ def on_stream_chunk(chunk, out, stats)
84
+ if chunk.chunk_text.include?("b")
85
+ out.write(chunk.chunk_text)
86
+ end
87
+ 0
88
+ end
89
+ end
90
+
91
+ # ---- mount routes (build proxy with runtime port) ----
92
+
93
+ get '/p/pass/:port' do
94
+ PassProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
95
+ res.body
96
+ end
97
+
98
+ get '/p/finalize/:port' do
99
+ FinalizeProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
100
+ res.body
101
+ end
102
+
103
+ get '/p/prefix/:port' do
104
+ PrefixProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
105
+ res.body
106
+ end
107
+
108
+ get '/p/drop/:port' do
109
+ DropProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
110
+ res.body
111
+ end
112
+
113
+ # Control: the upstream itself, fetched directly.
114
+ get '/direct/:port' do
115
+ r = Tep::Http.get("http://127.0.0.1:" + params[:port] + "/sse_upstream")
116
+ "status=" + r.status.to_s
117
+ end
118
+ RB
119
+
120
+ def test_streams_all_events_through
121
+ res = get("/p/pass/#{@port}")
122
+ assert_equal "200", res.code
123
+ assert_equal "chunked", res["transfer-encoding"]
124
+ assert_equal "data: a\n\ndata: b\n\ndata: c\n\n", res.body
125
+ end
126
+
127
+ def test_on_stream_end_fires_once_with_stats
128
+ res = get("/p/finalize/#{@port}")
129
+ assert_equal "200", res.code
130
+ # Three upstream events, then exactly one finalizer event carrying
131
+ # the chunk count.
132
+ assert_equal "data: a\n\ndata: b\n\ndata: c\n\ndata: count=3\n\n", res.body
133
+ end
134
+
135
+ def test_on_stream_chunk_can_transform
136
+ res = get("/p/prefix/#{@port}")
137
+ assert_equal "200", res.code
138
+ assert_equal "x-data: a\n\nx-data: b\n\nx-data: c\n\n", res.body
139
+ end
140
+
141
+ def test_on_stream_chunk_can_drop
142
+ res = get("/p/drop/#{@port}")
143
+ assert_equal "200", res.code
144
+ assert_equal "data: b\n\n", res.body
145
+ end
146
+ end
@@ -0,0 +1,397 @@
1
+ require_relative "helper"
2
+ require "shellwords"
3
+
4
+ # HTTP-level smoke tests for the real-world examples we claim to
5
+ # support. Each test compiles the example to a temporary binary,
6
+ # starts it on a fresh port, probes it with curl-equivalent
7
+ # requests, and kills it.
8
+ #
9
+ # Failure mode this catches: an example that *compiles* but
10
+ # doesn't actually serve correctly (the SINATRA_COMPAT.md matrix
11
+ # previously called out only build vs. serve via spot-checks; this
12
+ # is the automated version).
13
+ #
14
+ # These run alongside the curated tests but are stamped at the
15
+ # real_world/ source paths so they don't conflict with anything
16
+ # else.
17
+ class TestRealWorld < TepTest
18
+ # Override TepTest's class-level boot. Each test in here brings
19
+ # up its own example binary.
20
+ def self.boot!; end
21
+ def setup; end
22
+ def teardown; end
23
+
24
+ EXAMPLES_DIR = File.expand_path("real_world", __dir__)
25
+ PORT_BASE = 4900 + ($$ % 100)
26
+
27
+ @@port_counter = 0
28
+ def self.next_port
29
+ @@port_counter += 1
30
+ PORT_BASE + 100 + @@port_counter
31
+ end
32
+
33
+ def with_app(example_filename)
34
+ src = File.join(EXAMPLES_DIR, example_filename)
35
+ bin = Dir.mktmpdir + "/app"
36
+ out = `#{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
37
+ raise "build failed:\n#{out}" unless $?.success?
38
+ port = TestRealWorld.next_port
39
+ pid = Process.spawn(bin, "-p", port.to_s, "-q",
40
+ pgroup: true, out: "/dev/null", err: "/dev/null")
41
+ wait_for_port(port)
42
+ @port = port
43
+ begin
44
+ yield port
45
+ ensure
46
+ TepHarness.reap(pid)
47
+ end
48
+ end
49
+
50
+ def wait_for_port(port, timeout: 3.0)
51
+ deadline = Time.now + timeout
52
+ while Time.now < deadline
53
+ begin
54
+ TCPSocket.new("127.0.0.1", port).close
55
+ return
56
+ rescue Errno::ECONNREFUSED
57
+ sleep 0.05
58
+ end
59
+ end
60
+ raise "server on :#{port} didn't come up"
61
+ end
62
+
63
+ # ---- 01: simple ----
64
+
65
+ def test_01_simple_root_returns_text
66
+ with_app("01_simple.rb") do
67
+ res = get("/")
68
+ assert_equal "200", res.code
69
+ assert_match(/this is a simple app/, res.body)
70
+ end
71
+ end
72
+
73
+ # ---- 02: lifecycle ----
74
+
75
+ def test_02_lifecycle_root_renders
76
+ with_app("02_lifecycle.rb") do
77
+ res = get("/")
78
+ assert_equal "200", res.code
79
+ assert_match(/lifecycle events/, res.body)
80
+ end
81
+ end
82
+
83
+ # ---- 04: health api ----
84
+
85
+ def test_04_health_endpoints
86
+ with_app("04_health_api.rb") do
87
+ assert_match(/"status":"ok"/, get("/healthz").body)
88
+ assert_match(/"version":"1\.4\.2"/, get("/version").body)
89
+ assert_match(/"endpoints"/, get("/").body)
90
+ # not_found block returns a JSON 404
91
+ res = get("/missing")
92
+ assert_equal "404", res.code
93
+ assert_match(/"error":"not found"/, res.body)
94
+ assert_match(/"path":"\/missing"/, res.body)
95
+ end
96
+ end
97
+
98
+ # ---- 05: todo api ----
99
+
100
+ def test_05_todo_crud_round_trip
101
+ with_app("05_todo_api.rb") do
102
+ # Empty list at boot.
103
+ assert_equal "[]", get("/todos").body.strip
104
+
105
+ # Create two.
106
+ r1 = post("/todos", "text=buy-milk")
107
+ assert_match(/"id":1,"text":"buy-milk"/, r1.body)
108
+ r2 = post("/todos", "text=ship-tep")
109
+ assert_match(/"id":2,"text":"ship-tep"/, r2.body)
110
+
111
+ # List has both.
112
+ list = get("/todos").body
113
+ assert_match(/"id":1/, list)
114
+ assert_match(/"id":2/, list)
115
+
116
+ # Delete the first.
117
+ d = delete("/todos/1")
118
+ assert_match(/"deleted":1/, d.body)
119
+
120
+ # 404 on missing id.
121
+ d404 = delete("/todos/9999")
122
+ assert_equal "404", d404.code
123
+ end
124
+ end
125
+
126
+ # ---- 06: basic auth ----
127
+
128
+ def test_06_basic_auth_blocks_admin_without_token
129
+ with_app("06_basic_auth.rb") do
130
+ assert_equal "200", get("/").code
131
+
132
+ r_no = get("/admin/dashboard")
133
+ assert_equal "401", r_no.code
134
+
135
+ r_bad = get("/admin/dashboard", {"x-token" => "wrong"})
136
+ assert_equal "401", r_bad.code
137
+
138
+ r_ok = get("/admin/dashboard", {"x-token" => "sekret-42"})
139
+ assert_equal "200", r_ok.code
140
+ assert_match(/admin: ok/, r_ok.body)
141
+
142
+ assert_equal "200", get("/admin/users", {"x-token" => "sekret-42"}).code
143
+ end
144
+ end
145
+
146
+ # ---- showcase: examples/blog ----
147
+ # Exercises sqlite + json + jwt + password + sessions + erb-with-
148
+ # ivars + logger + security in one app. The blog ships an admin
149
+ # user (alice / hunter2) on first boot.
150
+
151
+ def with_blog
152
+ src = File.expand_path("../examples/blog/app.rb", __dir__)
153
+ bin = Dir.mktmpdir + "/blog"
154
+ db = File.join(File.dirname(bin), "blog.db")
155
+ File.unlink(db) if File.exist?(db)
156
+ out = `TEP_BLOG_DB=#{Shellwords.escape(db)} #{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
157
+ raise "blog build failed:\n#{out}" unless $?.success?
158
+ port = TestRealWorld.next_port
159
+ pid = Process.spawn({"TEP_BLOG_DB" => db}, bin, "-p", port.to_s, "-q",
160
+ pgroup: true, out: "/dev/null", err: "/dev/null")
161
+ wait_for_port(port)
162
+ @port = port
163
+ begin
164
+ yield port
165
+ ensure
166
+ TepHarness.reap(pid)
167
+ end
168
+ end
169
+
170
+ def test_blog_homepage_lists_posts
171
+ with_blog do
172
+ res = get("/")
173
+ assert_equal "200", res.code
174
+ assert_match(/tep blog/, res.body)
175
+ # First boot seeds an intro post so the homepage isn't empty.
176
+ assert_match(/Welcome to tep \+ spinel/, res.body)
177
+ end
178
+ end
179
+
180
+ def test_blog_homepage_renders_seed_post
181
+ with_blog do
182
+ res = get("/post/1")
183
+ assert_equal "200", res.code
184
+ assert_match(/<h1>Welcome to tep \+ spinel<\/h1>/, res.body)
185
+ assert_match(/by alice/, res.body)
186
+ end
187
+ end
188
+
189
+ def test_blog_api_token_and_post_round_trip
190
+ with_blog do
191
+ # Issue a JWT for the seeded admin.
192
+ tok_res = post("/api/token", '{"user":"alice","password":"hunter2"}')
193
+ assert_equal "200", tok_res.code
194
+ token = tok_res.body[/"token":"([^"]+)"/, 1]
195
+ refute_nil token, "token field present in /api/token response"
196
+
197
+ # Create a post via the JSON API. With the seed post present
198
+ # this is row 2; assertion is shape-only.
199
+ hdr = {"Authorization" => "Bearer #{token}"}
200
+ r_create = post("/api/posts", '{"title":"hello","body":"first post"}', hdr)
201
+ assert_equal "201", r_create.code
202
+ created_id = r_create.body[/"id":(\d+)/, 1].to_i
203
+ assert created_id >= 1
204
+
205
+ # Read it back via the public list.
206
+ r_list = get("/api/posts")
207
+ assert_match(/"title":"hello"/, r_list.body)
208
+ assert_match(/"author":"alice"/, r_list.body)
209
+
210
+ # Web view renders the post.
211
+ r_show = get("/post/#{created_id}")
212
+ assert_equal "200", r_show.code
213
+ assert_match(/<h1>hello<\/h1>/, r_show.body)
214
+ assert_match(/by alice/, r_show.body)
215
+ end
216
+ end
217
+
218
+ def test_blog_api_token_rejects_bad_password
219
+ with_blog do
220
+ res = post("/api/token", '{"user":"alice","password":"wrong"}')
221
+ assert_equal "401", res.code
222
+ assert_match(/invalid credentials/, res.body)
223
+ end
224
+ end
225
+
226
+ def test_blog_api_posts_requires_jwt
227
+ with_blog do
228
+ # No Authorization header.
229
+ r1 = post("/api/posts", '{"title":"x","body":"y"}')
230
+ assert_equal "401", r1.code
231
+
232
+ # Tampered token.
233
+ r2 = post("/api/posts", '{"title":"x","body":"y"}',
234
+ {"Authorization" => "Bearer not.a.token"})
235
+ assert_equal "401", r2.code
236
+ end
237
+ end
238
+
239
+ def test_blog_web_login_protects_admin
240
+ with_blog do
241
+ # Without session: 401 on admin.
242
+ r_admin = get("/admin/new")
243
+ assert_equal "401", r_admin.code
244
+
245
+ # With a successful login + cookie jar...
246
+ uri = URI("http://127.0.0.1:#{@port}/login")
247
+ net = Net::HTTP.new(uri.host, uri.port)
248
+ r_login = net.post(uri.path, "user=alice&password=hunter2")
249
+ assert_equal "302", r_login.code
250
+ cookie = r_login["Set-Cookie"]
251
+ refute_nil cookie
252
+
253
+ r_admin2 = get("/admin/new", {"Cookie" => cookie.split(";").first})
254
+ assert_equal "200", r_admin2.code
255
+ assert_match(/posting as.*alice/, r_admin2.body)
256
+ end
257
+ end
258
+
259
+ # ---- showcase: examples/chat ----
260
+ # Live chat with SSE streaming + presence. Each open SSE
261
+ # connection occupies one tep worker, so we boot with -w 4.
262
+
263
+ def with_chat
264
+ src = File.expand_path("../examples/chat/app.rb", __dir__)
265
+ bin = Dir.mktmpdir + "/chat"
266
+ db = File.join(File.dirname(bin), "chat.db")
267
+ File.unlink(db) if File.exist?(db)
268
+ out = `TEP_CHAT_DB=#{Shellwords.escape(db)} #{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
269
+ raise "chat build failed:\n#{out}" unless $?.success?
270
+ port = TestRealWorld.next_port
271
+ # Single worker, fresh process group so we can SIGTERM the
272
+ # whole tree on teardown. Prefork (-w >1) leaks orphan workers
273
+ # under macOS SO_REUSEPORT semantics that confuse subsequent
274
+ # boots in the same test run.
275
+ pid = Process.spawn({"TEP_CHAT_DB" => db}, bin, "-p", port.to_s, "-w", "1", "-q",
276
+ pgroup: true, out: "/dev/null", err: "/dev/null")
277
+ wait_for_port(port)
278
+ @port = port
279
+ begin
280
+ yield port
281
+ ensure
282
+ TepHarness.reap(pid)
283
+ end
284
+ end
285
+
286
+ def test_chat_homepage_renders
287
+ with_chat do
288
+ res = get("/")
289
+ assert_equal "200", res.code
290
+ assert_match(/tep chat/, res.body)
291
+ assert_match(/EventSource/, res.body)
292
+ end
293
+ end
294
+
295
+ def test_chat_send_then_recent
296
+ with_chat do
297
+ r1 = post("/chat/send", "author=alice&body=hello")
298
+ assert_equal "200", r1.code
299
+ assert_match(/"id":1/, r1.body)
300
+
301
+ r2 = post("/chat/send", "author=bob&body=hi+alice")
302
+ assert_match(/"id":2/, r2.body)
303
+
304
+ list = get("/chat/recent")
305
+ assert_match(/"author":"alice","body":"hello"/, list.body)
306
+ assert_match(/"author":"bob","body":"hi alice"/, list.body)
307
+ end
308
+ end
309
+
310
+ def test_chat_send_validates_required_fields
311
+ with_chat do
312
+ assert_equal "400", post("/chat/send", "body=missing-author").code
313
+ assert_equal "400", post("/chat/send", "author=alice").code
314
+ end
315
+ end
316
+
317
+ def test_chat_who_reflects_heartbeats
318
+ with_chat do
319
+ # No-one before any heartbeat.
320
+ assert_equal "[]", get("/chat/who").body.strip
321
+
322
+ post("/chat/heartbeat", "user=alice")
323
+ post("/chat/heartbeat", "user=bob")
324
+ who = get("/chat/who").body
325
+ assert_match(/"user":"alice"/, who)
326
+ assert_match(/"user":"bob"/, who)
327
+ end
328
+ end
329
+
330
+ def test_chat_recent_since_param
331
+ with_chat do
332
+ post("/chat/send", "author=a&body=one")
333
+ post("/chat/send", "author=b&body=two")
334
+ post("/chat/send", "author=c&body=three")
335
+
336
+ # since=1 should drop msg #1 and return #2 + #3.
337
+ list = get("/chat/recent?since=1").body
338
+ refute_match(/"body":"one"/, list)
339
+ assert_match(/"body":"two"/, list)
340
+ assert_match(/"body":"three"/, list)
341
+ end
342
+ end
343
+
344
+ def test_chat_serves_bundled_assets
345
+ with_chat do
346
+ css = get("/style.css")
347
+ assert_equal "200", css.code
348
+ assert_match(/text\/css/, css["content-type"])
349
+ assert_match(/--accent/, css.body) # spot-check our content
350
+ assert_match(/max-age=3600/, css["cache-control"])
351
+
352
+ svg = get("/logo.svg")
353
+ assert_equal "200", svg.code
354
+ assert_match(/image\/svg\+xml/, svg["content-type"])
355
+ assert_match(/<svg/, svg.body)
356
+
357
+ # An asset that isn't bundled falls through to 404 via the
358
+ # normal not-found path, not the asset layer.
359
+ assert_equal "404", get("/missing.css").code
360
+ end
361
+ end
362
+
363
+ def test_chat_stream_emits_backlog_and_keepalive
364
+ with_chat do
365
+ # Seed messages BEFORE the stream opens.
366
+ post("/chat/send", "author=a&body=pre1")
367
+ post("/chat/send", "author=b&body=pre2")
368
+
369
+ # Pull bytes directly from the SSE socket so we don't have to
370
+ # wait STREAM_MAX (30s) for Net::HTTP to call it done.
371
+ sock = TCPSocket.new("127.0.0.1", @port)
372
+ sock.write("GET /chat/stream?since=0 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
373
+ events = String.new
374
+ deadline = Time.now + 4
375
+ while Time.now < deadline
376
+ IO.select([sock], nil, nil, 0.5) or next
377
+ chunk = sock.read_nonblock(4096) rescue nil
378
+ break if chunk.nil? || chunk.empty?
379
+ events << chunk
380
+ # Stop early once we've seen everything we expect.
381
+ break if events.include?("pre1") && events.include?("pre2") && events.include?(": tick")
382
+ end
383
+ sock.close
384
+
385
+ # Live "send while streaming" is a separate concurrency
386
+ # property that depends on prefork SO_REUSEPORT actually
387
+ # load-balancing -- which it doesn't reliably on macOS. The
388
+ # streaming pipeline itself (backlog + keepalive frames) is
389
+ # what we cover here.
390
+ assert_match(/"body":"pre1"/, events)
391
+ assert_match(/"body":"pre2"/, events)
392
+ assert_match(/: tick/, events)
393
+ assert_match(/Transfer-Encoding: chunked/i, events)
394
+ assert_match(/Content-Type: text\/event-stream/i, events)
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "helper"
2
+
3
+ class TestRegexRoutes < TepTest
4
+ app_source <<~'RB'
5
+ require 'sinatra'
6
+
7
+ get %r{^/posts/(\d+)$} do
8
+ "post id=" + params["1"]
9
+ end
10
+
11
+ get %r{^/users/([a-z]+)/posts/(\d+)$} do
12
+ "user=" + params["1"] + " post=" + params["2"]
13
+ end
14
+
15
+ get '/literal/path' do
16
+ "literal wins"
17
+ end
18
+
19
+ # Regex that overlaps with the literal -- literal must take
20
+ # precedence (we register it after, but match() tries all literal
21
+ # routes before any regex one).
22
+ get %r{^/literal/.+$} do
23
+ "regex fallback"
24
+ end
25
+ RB
26
+
27
+ def test_single_capture
28
+ res = get("/posts/42")
29
+ assert_equal "200", res.code
30
+ assert_equal "post id=42", res.body
31
+ end
32
+
33
+ def test_two_captures
34
+ res = get("/users/alice/posts/7")
35
+ assert_equal "user=alice post=7", res.body
36
+ end
37
+
38
+ def test_no_match_falls_through_to_404
39
+ res = get("/posts/abc")
40
+ assert_equal "404", res.code
41
+ end
42
+
43
+ def test_literal_route_beats_regex
44
+ res = get("/literal/path")
45
+ assert_equal "literal wins", res.body
46
+ end
47
+
48
+ def test_regex_falls_through_when_literal_misses
49
+ res = get("/literal/elsewhere")
50
+ assert_equal "regex fallback", res.body
51
+ end
52
+ end
@@ -0,0 +1,102 @@
1
+ require_relative "helper"
2
+
3
+ # Rack::Request-style convenience methods. tep doesn't terminate TLS,
4
+ # so .scheme / .ssl? read X-Forwarded-Proto (set by any reasonable
5
+ # reverse proxy).
6
+ class TestRequestMethods < TepTest
7
+ app_source <<~RB
8
+ require 'sinatra'
9
+
10
+ get '/host' do
11
+ request.host
12
+ end
13
+
14
+ get '/ua' do
15
+ request.user_agent
16
+ end
17
+
18
+ get '/ref' do
19
+ request.referer + " :: " + request.referrer
20
+ end
21
+
22
+ get '/scheme' do
23
+ request.scheme + " ssl?=" + request.ssl?.to_s
24
+ end
25
+
26
+ get '/accept-and-ct' do
27
+ "accept=" + request.accept + " ct=" + request.content_type
28
+ end
29
+ RB
30
+
31
+ def test_host
32
+ res = get("/host", "Host" => "example.com:8080")
33
+ assert_equal "example.com:8080", res.body
34
+ end
35
+
36
+ def test_user_agent
37
+ res = get("/ua", "User-Agent" => "tep-test/1.0")
38
+ assert_equal "tep-test/1.0", res.body
39
+ end
40
+
41
+ def test_referer_and_referrer
42
+ res = get("/ref", "Referer" => "https://prev.example/x")
43
+ assert_equal "https://prev.example/x :: https://prev.example/x", res.body
44
+ end
45
+
46
+ def test_scheme_default_http
47
+ res = get("/scheme")
48
+ assert_equal "http ssl?=false", res.body
49
+ end
50
+
51
+ def test_scheme_x_forwarded_proto
52
+ res = get("/scheme", "X-Forwarded-Proto" => "https")
53
+ assert_equal "https ssl?=true", res.body
54
+ end
55
+
56
+ def test_accept_and_content_type
57
+ res = post("/accept-and-ct", "x=1",
58
+ "Accept" => "application/json",
59
+ "Content-Type" => "application/x-www-form-urlencoded")
60
+ assert_equal "404", res.code # POST not declared; just confirming the route table behaves
61
+ # Re-fetch the GET form for headers we actually want to inspect.
62
+ res2 = get("/accept-and-ct", "Accept" => "application/json")
63
+ assert_match(/accept=application\/json/, res2.body)
64
+ end
65
+ end
66
+
67
+ # `request.body.read` -- Sinatra apps commonly treat request.body as
68
+ # IO and call .read on it; tep's req.body is already a String, so the
69
+ # bin/tep translator rewrites `request.body.read` -> `req.body` so the
70
+ # Sinatra-style handler compiles + serves unchanged.
71
+ class TestRequestBodyRead < TepTest
72
+ app_source <<~RB
73
+ require 'sinatra'
74
+
75
+ post '/echo' do
76
+ content_type 'text/plain'
77
+ request.body.read
78
+ end
79
+
80
+ post '/echo-twice' do
81
+ # Hit .read twice -- a Sinatra IO would return "" on the second
82
+ # call (cursor at EOF); for tep's no-op .read it just returns
83
+ # the same String again. The expected behaviour here is "tep
84
+ # gives you the body each time"; apps that rely on the IO
85
+ # cursor semantics need to rewrite to a single .read + store.
86
+ content_type 'text/plain'
87
+ request.body.read + "|" + request.body.read
88
+ end
89
+ RB
90
+
91
+ def test_request_body_read_returns_raw_body
92
+ res = post("/echo", "hello world")
93
+ assert_equal "200", res.code
94
+ assert_equal "hello world", res.body
95
+ end
96
+
97
+ def test_request_body_read_idempotent
98
+ res = post("/echo-twice", "abc")
99
+ assert_equal "200", res.code
100
+ assert_equal "abc|abc", res.body
101
+ end
102
+ end