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,197 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Broadcast: in-process topic broker. v1 stores (topic, fd)
4
+ # pairs; publish writes payload bytes to every matching fd. These
5
+ # tests exercise the registry shape via fake fds (-1 / synthetic
6
+ # ints); real delivery to live sockets gets covered when the WS
7
+ # battery lands and integrates Broadcast end-to-end. The publish()
8
+ # return value is "matched count," not "successful writes" -- bad
9
+ # fds silently fail at sphttp_write_str without affecting the
10
+ # match count.
11
+ class TestBroadcast < TepTest
12
+ app_source <<~RB
13
+ require 'sinatra'
14
+
15
+ before do
16
+ res.headers["Content-Type"] = "text/plain"
17
+ end
18
+
19
+ # Reset between cases so test ordering doesn't matter.
20
+ get '/reset' do
21
+ Tep::Broadcast.clear.to_s
22
+ end
23
+
24
+ get '/subscribe' do
25
+ topic = params[:topic]
26
+ fd = params[:fd].to_i
27
+ Tep::Broadcast.subscribe(topic, fd).to_s
28
+ end
29
+
30
+ get '/subscribe_ws' do
31
+ topic = params[:topic]
32
+ fd = params[:fd].to_i
33
+ Tep::Broadcast.subscribe_ws(topic, fd).to_s
34
+ end
35
+
36
+ get '/unsubscribe' do
37
+ sub_id = params[:sub_id].to_i
38
+ Tep::Broadcast.unsubscribe(sub_id).to_s
39
+ end
40
+
41
+ get '/unsubscribe_fd' do
42
+ fd = params[:fd].to_i
43
+ Tep::Broadcast.unsubscribe_fd(fd).to_s
44
+ end
45
+
46
+ get '/publish' do
47
+ topic = params[:topic]
48
+ payload = params[:payload]
49
+ Tep::Broadcast.publish(topic, payload).to_s
50
+ end
51
+
52
+ get '/subscriber_count' do
53
+ Tep::Broadcast.subscriber_count.to_s
54
+ end
55
+
56
+ get '/subscribers_for' do
57
+ topic = params[:topic]
58
+ Tep::Broadcast.subscribers_for(topic).to_s
59
+ end
60
+ RB
61
+
62
+ # Helper: reset between tests so state doesn't carry.
63
+ def setup
64
+ super
65
+ get("/reset")
66
+ end
67
+
68
+ def subscribe(topic, fd)
69
+ get("/subscribe?topic=#{topic}&fd=#{fd}").body.to_i
70
+ end
71
+
72
+ def publish(topic, payload)
73
+ get("/publish?topic=#{topic}&payload=#{payload}").body.to_i
74
+ end
75
+
76
+ def subscriber_count
77
+ get("/subscriber_count").body.to_i
78
+ end
79
+
80
+ def subscribers_for(topic)
81
+ get("/subscribers_for?topic=#{topic}").body.to_i
82
+ end
83
+
84
+ # ---- empty registry ----
85
+
86
+ def test_publish_to_empty_registry_returns_zero
87
+ assert_equal 0, publish("room:lobby", "hello")
88
+ end
89
+
90
+ def test_subscriber_count_starts_at_zero
91
+ assert_equal 0, subscriber_count
92
+ end
93
+
94
+ def test_subscribers_for_unknown_topic_is_zero
95
+ assert_equal 0, subscribers_for("never-subscribed")
96
+ end
97
+
98
+ # ---- subscribe + count ----
99
+
100
+ def test_subscribe_grows_registry
101
+ subscribe("room:lobby", -1)
102
+ assert_equal 1, subscriber_count
103
+ assert_equal 1, subscribers_for("room:lobby")
104
+ end
105
+
106
+ def test_multiple_subscribers_same_topic
107
+ subscribe("room:lobby", -1)
108
+ subscribe("room:lobby", -2)
109
+ subscribe("room:lobby", -3)
110
+ assert_equal 3, subscribers_for("room:lobby")
111
+ end
112
+
113
+ def test_subscribers_segregated_by_topic
114
+ subscribe("room:lobby", -1)
115
+ subscribe("room:lobby", -2)
116
+ subscribe("room:other", -3)
117
+ assert_equal 2, subscribers_for("room:lobby")
118
+ assert_equal 1, subscribers_for("room:other")
119
+ end
120
+
121
+ # ---- publish matching ----
122
+
123
+ def test_publish_returns_matched_count
124
+ subscribe("room:lobby", -1)
125
+ subscribe("room:lobby", -2)
126
+ subscribe("room:other", -3)
127
+ assert_equal 2, publish("room:lobby", "hi")
128
+ end
129
+
130
+ def test_publish_to_unmatched_topic_zero
131
+ subscribe("room:lobby", -1)
132
+ assert_equal 0, publish("never", "hi")
133
+ end
134
+
135
+ # ---- unsubscribe (by sub_id) ----
136
+
137
+ def test_unsubscribe_by_id_drops_one
138
+ sub_id = subscribe("room:lobby", -1)
139
+ subscribe("room:lobby", -2)
140
+ get("/unsubscribe?sub_id=#{sub_id}")
141
+ assert_equal 1, subscribers_for("room:lobby")
142
+ end
143
+
144
+ # ---- unsubscribe_fd (by fd, multi-topic) ----
145
+
146
+ def test_unsubscribe_fd_drops_all_for_fd
147
+ subscribe("room:lobby", -1)
148
+ subscribe("room:other", -1) # same fd, different topic
149
+ subscribe("room:lobby", -2)
150
+ dropped = get("/unsubscribe_fd?fd=-1").body.to_i
151
+ assert_equal 2, dropped
152
+ assert_equal 1, subscribers_for("room:lobby")
153
+ assert_equal 0, subscribers_for("room:other")
154
+ end
155
+
156
+ def test_unsubscribe_fd_unknown_zero
157
+ subscribe("room:lobby", -1)
158
+ dropped = get("/unsubscribe_fd?fd=-999").body.to_i
159
+ assert_equal 0, dropped
160
+ end
161
+
162
+ # ---- subscribe_ws (WebSocket frame mode) ----
163
+
164
+ def test_subscribe_ws_grows_registry
165
+ get("/subscribe_ws?topic=room:lobby&fd=-1")
166
+ assert_equal 1, subscriber_count
167
+ assert_equal 1, subscribers_for("room:lobby")
168
+ end
169
+
170
+ def test_subscribe_ws_publish_match_count
171
+ # Subscribe two WS, one raw -- all three should match a publish
172
+ # to that topic (delivery mode doesn't affect match counting).
173
+ get("/subscribe_ws?topic=room:lobby&fd=-1")
174
+ get("/subscribe_ws?topic=room:lobby&fd=-2")
175
+ get("/subscribe?topic=room:lobby&fd=-3")
176
+ assert_equal 3, publish("room:lobby", "hi")
177
+ end
178
+
179
+ def test_subscribe_ws_unsubscribe_fd_drops
180
+ # Mixed-mode subscriptions for one fd: subscribe_ws on a
181
+ # different topic + subscribe on the same fd. unsubscribe_fd
182
+ # drops both.
183
+ get("/subscribe_ws?topic=room:lobby&fd=-1")
184
+ get("/subscribe?topic=room:other&fd=-1")
185
+ dropped = get("/unsubscribe_fd?fd=-1").body.to_i
186
+ assert_equal 2, dropped
187
+ end
188
+
189
+ # ---- clear ----
190
+
191
+ def test_clear_drops_everything
192
+ subscribe("room:lobby", -1)
193
+ subscribe("room:other", -2)
194
+ get("/reset")
195
+ assert_equal 0, subscriber_count
196
+ end
197
+ end
@@ -0,0 +1,135 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Broadcast PG backend: cross-worker pub/sub via
4
+ # LISTEN/NOTIFY. Gated on PG_TEST_URL like test_pg.rb.
5
+ #
6
+ # PG_TEST_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
7
+ # ruby test/test_broadcast_pg.rb
8
+ #
9
+ # Test strategy: configure the backend at on_start, then exercise
10
+ # publish() + poll_pg_once() within a single tep app instance. PG's
11
+ # LISTEN/NOTIFY delivers a worker's own NOTIFYs back to it (the
12
+ # "LISTEN sees own publishes" property), so a single-process app
13
+ # can validate the full wire round-trip without needing to spin up
14
+ # a second worker.
15
+ class TestBroadcastPg < TepTest
16
+ PG_URL = ENV["PG_TEST_URL"]
17
+ CHANNEL = "tep_broadcast_test_#{$$}"
18
+
19
+ app_source <<~RB
20
+ require 'sinatra'
21
+
22
+ PG_URL = "#{PG_URL}"
23
+ CHANNEL = "#{CHANNEL}"
24
+
25
+ on_start do
26
+ Tep::Broadcast.enable_pg_backend(PG_URL, CHANNEL)
27
+ end
28
+
29
+ before do
30
+ res.headers["Content-Type"] = "text/plain"
31
+ end
32
+
33
+ get '/reset' do
34
+ Tep::Broadcast.clear.to_s
35
+ end
36
+
37
+ get '/subscribe' do
38
+ topic = params[:topic]
39
+ fd = params[:fd].to_i
40
+ Tep::Broadcast.subscribe(topic, fd).to_s
41
+ end
42
+
43
+ get '/publish' do
44
+ topic = params[:topic]
45
+ payload = params[:payload]
46
+ Tep::Broadcast.publish(topic, payload).to_s
47
+ end
48
+
49
+ get '/poll' do
50
+ timeout = params[:timeout].to_i
51
+ Tep::Broadcast.poll_pg_once(timeout).to_s
52
+ end
53
+
54
+ get '/encode_wire' do
55
+ topic = params[:topic]
56
+ payload = params[:payload]
57
+ Tep::Broadcast.encode_wire(topic, payload)
58
+ end
59
+
60
+ get '/decode_wire' do
61
+ wire = params[:wire]
62
+ Tep::Broadcast.deliver_wire_local(wire).to_s
63
+ end
64
+ RB
65
+
66
+ def setup
67
+ if PG_URL.nil? || PG_URL.empty?
68
+ skip "PG_TEST_URL not set (e.g. PG_TEST_URL=postgresql:///postgres). " \
69
+ "See test/test_pg.rb header for the docker recipe."
70
+ end
71
+ super
72
+ get("/reset")
73
+ end
74
+
75
+ # ---- Wire format round-trip (no PG, no NOTIFY -- pure encoding) ----
76
+
77
+ def test_encode_wire_length_prefixed
78
+ res = get("/encode_wire?topic=room:lobby&payload=hello")
79
+ # "10:room:lobbyhello" -- 10 chars in topic "room:lobby" then payload "hello"
80
+ assert_equal "10:room:lobbyhello", res.body
81
+ end
82
+
83
+ def test_encode_wire_empty_payload
84
+ res = get("/encode_wire?topic=t&payload=")
85
+ assert_equal "1:t", res.body
86
+ end
87
+
88
+ def test_decode_wire_delivers_to_local_subs
89
+ # Subscribe a fake fd to a topic, then decode-and-deliver a
90
+ # wire-format payload as if it had come in via PG NOTIFY.
91
+ get("/subscribe?topic=room:lobby&fd=-1")
92
+ res = get("/decode_wire?wire=10:room:lobbyhello")
93
+ # Matched 1 local subscriber.
94
+ assert_equal "1", res.body
95
+ end
96
+
97
+ def test_decode_wire_unsubscribed_topic_zero
98
+ res = get("/decode_wire?wire=4:nope")
99
+ assert_equal "0", res.body
100
+ end
101
+
102
+ # ---- End-to-end PG NOTIFY round trip ----
103
+
104
+ def test_publish_then_poll_round_trips_via_pg
105
+ # Publish a message -- NOTIFY's PG.
106
+ get("/publish?topic=pg_round_trip&payload=ping")
107
+ # Poll for the NOTIFY (we sent it; LISTEN sees own publishes).
108
+ res = get("/poll?timeout=2000")
109
+ assert_equal "1", res.body
110
+ end
111
+
112
+ def test_poll_returns_zero_on_timeout
113
+ # With no preceding publish + no other publisher on the channel,
114
+ # poll should time out cleanly.
115
+ res = get("/poll?timeout=100")
116
+ assert_equal "0", res.body
117
+ end
118
+
119
+ def test_publish_with_local_sub_also_matches_local
120
+ # Local fan-out still works alongside PG NOTIFY. Subscribe a
121
+ # local fake fd, publish -- match count reflects the local sub.
122
+ get("/subscribe?topic=mixed_topic&fd=-1")
123
+ res = get("/publish?topic=mixed_topic&payload=hi")
124
+ assert_equal "1", res.body
125
+ end
126
+
127
+ def test_publish_with_no_local_sub_matches_zero_but_still_notifies
128
+ # No local subs -- match count is 0, but publish still ran the
129
+ # PG NOTIFY (subsequent poll confirms).
130
+ res = get("/publish?topic=remote_only&payload=hi")
131
+ assert_equal "0", res.body
132
+ poll_res = get("/poll?timeout=2000")
133
+ assert_equal "1", poll_res.body
134
+ end
135
+ end
@@ -0,0 +1,98 @@
1
+ require_relative "helper"
2
+
3
+ # HTTP caching battery (issue #152): Cache-Control / ETag / Last-Modified
4
+ # response helpers + conditional-GET 304 short-circuit.
5
+ class TestCache < TepTest
6
+ app_source <<~RB
7
+ require 'sinatra'
8
+
9
+ get '/etag' do
10
+ res.etag("v1")
11
+ "etag-body"
12
+ end
13
+
14
+ get '/lastmod' do
15
+ res.last_modified(1700000000) # fixed epoch -> stable Last-Modified
16
+ "lastmod-body"
17
+ end
18
+
19
+ get '/cc' do
20
+ res.cache_control("public, max-age=60")
21
+ "cc-body"
22
+ end
23
+
24
+ get '/exp' do
25
+ res.expires(60)
26
+ "exp-body"
27
+ end
28
+
29
+ get '/nostore' do
30
+ res.no_store
31
+ "ns-body"
32
+ end
33
+ RB
34
+
35
+ # ---- ETag / If-None-Match ----
36
+
37
+ def test_etag_present_on_normal_get
38
+ res = get("/etag")
39
+ assert_equal "200", res.code
40
+ assert_equal "\"v1\"", res["ETag"]
41
+ assert_equal "etag-body", res.body
42
+ end
43
+
44
+ def test_if_none_match_match_returns_304_no_body
45
+ res = get("/etag", {"If-None-Match" => "\"v1\""})
46
+ assert_equal "304", res.code
47
+ assert_equal "", res.body.to_s
48
+ assert_equal "\"v1\"", res["ETag"] # validator preserved on 304
49
+ end
50
+
51
+ def test_if_none_match_star_returns_304
52
+ res = get("/etag", {"If-None-Match" => "*"})
53
+ assert_equal "304", res.code
54
+ end
55
+
56
+ def test_if_none_match_mismatch_returns_200
57
+ res = get("/etag", {"If-None-Match" => "\"other\""})
58
+ assert_equal "200", res.code
59
+ assert_equal "etag-body", res.body
60
+ end
61
+
62
+ # ---- Last-Modified / If-Modified-Since ----
63
+
64
+ def test_last_modified_header_is_http_date
65
+ res = get("/lastmod")
66
+ assert_equal "200", res.code
67
+ assert_match(/GMT\z/, res["Last-Modified"])
68
+ end
69
+
70
+ def test_if_modified_since_equal_returns_304
71
+ first = get("/lastmod")
72
+ res = get("/lastmod", {"If-Modified-Since" => first["Last-Modified"]})
73
+ assert_equal "304", res.code
74
+ assert_equal "", res.body.to_s
75
+ end
76
+
77
+ def test_if_modified_since_older_returns_200
78
+ res = get("/lastmod", {"If-Modified-Since" => "Sat, 01 Jan 2000 00:00:00 GMT"})
79
+ assert_equal "200", res.code
80
+ assert_equal "lastmod-body", res.body
81
+ end
82
+
83
+ # ---- Cache-Control / Expires ----
84
+
85
+ def test_cache_control_verbatim
86
+ assert_equal "public, max-age=60", get("/cc")["Cache-Control"]
87
+ end
88
+
89
+ def test_expires_sets_expires_and_max_age
90
+ res = get("/exp")
91
+ assert_equal "max-age=60", res["Cache-Control"]
92
+ assert_match(/GMT\z/, res["Expires"])
93
+ end
94
+
95
+ def test_no_store_shortcut
96
+ assert_equal "no-store", get("/nostore")["Cache-Control"]
97
+ end
98
+ end
@@ -0,0 +1,48 @@
1
+ require_relative "helper"
2
+
3
+ # Caching phase 2 (#152): static files served via send_file (public_dir)
4
+ # carry a size-mtime ETag + Last-Modified and revalidate to 304.
5
+ class TestCacheStatic < TepTest
6
+ app_source <<~RB
7
+ set :public_dir, '#{File.expand_path("../public", __dir__)}'
8
+
9
+ get '/' do
10
+ "root"
11
+ end
12
+ RB
13
+
14
+ def test_static_file_has_validators
15
+ res = get("/hello.txt")
16
+ assert_equal "200", res.code
17
+ refute_nil res["ETag"]
18
+ assert_match(/GMT\z/, res["Last-Modified"])
19
+ assert_match(/static file serving/, res.body)
20
+ end
21
+
22
+ def test_static_if_none_match_returns_304
23
+ etag = get("/hello.txt")["ETag"]
24
+ res = get("/hello.txt", {"If-None-Match" => etag})
25
+ assert_equal "304", res.code
26
+ assert_equal "", res.body.to_s
27
+ assert_equal etag, res["ETag"] # validator preserved on 304
28
+ end
29
+
30
+ def test_static_if_modified_since_equal_returns_304
31
+ lm = get("/hello.txt")["Last-Modified"]
32
+ res = get("/hello.txt", {"If-Modified-Since" => lm})
33
+ assert_equal "304", res.code
34
+ assert_equal "", res.body.to_s
35
+ end
36
+
37
+ def test_static_if_modified_since_old_returns_200
38
+ res = get("/hello.txt", {"If-Modified-Since" => "Sat, 01 Jan 2000 00:00:00 GMT"})
39
+ assert_equal "200", res.code
40
+ assert_match(/static file serving/, res.body)
41
+ end
42
+
43
+ def test_static_if_none_match_mismatch_returns_200
44
+ res = get("/hello.txt", {"If-None-Match" => "\"nope\""})
45
+ assert_equal "200", res.code
46
+ assert_match(/static file serving/, res.body)
47
+ end
48
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "helper"
2
+
3
+ class TestCookies < TepTest
4
+ app_source <<~RB
5
+ get '/echo' do
6
+ "name=" + cookies["name"] + " mood=" + cookies["mood"]
7
+ end
8
+
9
+ get '/set' do
10
+ set_cookie "user", "alice"
11
+ "ok"
12
+ end
13
+
14
+ get '/set-flagged' do
15
+ set_cookie "session_id", "xyz"
16
+ "ok"
17
+ end
18
+ RB
19
+
20
+ def test_round_trip
21
+ res = get("/echo", "Cookie" => "name=alice; mood=happy")
22
+ assert_equal "200", res.code
23
+ assert_equal "name=alice mood=happy", res.body
24
+ end
25
+
26
+ def test_url_decoded_value
27
+ res = get("/echo", "Cookie" => "name=hello%20world; mood=ok")
28
+ assert_equal "name=hello world mood=ok", res.body
29
+ end
30
+
31
+ def test_missing_cookie_is_empty
32
+ res = get("/echo", "Cookie" => "name=alice")
33
+ assert_equal "name=alice mood=", res.body
34
+ end
35
+
36
+ def test_set_cookie_writes_header
37
+ res = get("/set")
38
+ assert_equal "200", res.code
39
+ assert_equal "user=alice", res["set-cookie"]
40
+ end
41
+
42
+ def test_set_cookie_value_is_url_encoded
43
+ res = get("/set-flagged")
44
+ assert_match(/^session_id=xyz/, res["set-cookie"])
45
+ end
46
+
47
+ def test_no_cookie_header_no_crash
48
+ res = get("/echo")
49
+ assert_equal "200", res.code
50
+ assert_equal "name= mood=", res.body
51
+ end
52
+ end
data/test/test_erb.rb ADDED
@@ -0,0 +1,53 @@
1
+ require_relative "helper"
2
+
3
+ class TestErb < TepTest
4
+ app_source <<~RB
5
+ require 'sinatra'
6
+
7
+ set :views, '#{File.expand_path("views", __dir__)}'
8
+
9
+ get '/hello/:who' do
10
+ erb :hello, locals: { name: params[:who], mood: "happy" }
11
+ end
12
+
13
+ get '/sober/:who' do
14
+ erb :hello, locals: { name: params[:who], mood: "neutral" }
15
+ end
16
+
17
+ get '/list/:n' do
18
+ erb :list, locals: { count: params[:n] }
19
+ end
20
+
21
+ get '/no-locals' do
22
+ erb :hello
23
+ end
24
+ RB
25
+
26
+ def test_simple_interpolation
27
+ res = get("/hello/world")
28
+ assert_equal "200", res.code
29
+ assert_match(/hello, world!/, res.body)
30
+ end
31
+
32
+ def test_conditional_block
33
+ happy = get("/hello/world")
34
+ assert_match(/cheerful/, happy.body)
35
+ sober = get("/sober/world")
36
+ refute_match(/cheerful/, sober.body)
37
+ end
38
+
39
+ def test_loop_block
40
+ res = get("/list/3")
41
+ assert_equal "200", res.code
42
+ assert_match(/<li>item 0</, res.body)
43
+ assert_match(/<li>item 1</, res.body)
44
+ assert_match(/<li>item 2</, res.body)
45
+ refute_match(/<li>item 3</, res.body)
46
+ end
47
+
48
+ def test_no_locals_renders_empty_for_missing_keys
49
+ res = get("/no-locals")
50
+ assert_equal "200", res.code
51
+ assert_match(/hello, !/, res.body)
52
+ end
53
+ end
@@ -0,0 +1,58 @@
1
+ require_relative "helper"
2
+
3
+ # Sinatra-style `@ivar` template locals: a handler (or `before`
4
+ # filter) sets `@name = ...`, and the template reads it via
5
+ # `<%= @name %>`. The translator stores ivars on a per-request bag
6
+ # (req.ivars) and threads it as a second arg to `tep_view_<name>`.
7
+ class TestErbIvars < TepTest
8
+ app_source <<~RB
9
+ require 'sinatra'
10
+
11
+ set :views, '#{File.expand_path("views", __dir__)}'
12
+
13
+ before do
14
+ @greeting = "filter-said-hi"
15
+ end
16
+
17
+ get '/greet/:who/:n' do
18
+ @name = params[:who]
19
+ @count = params[:n]
20
+ erb :greet
21
+ end
22
+
23
+ get '/mixed/:who' do
24
+ @name = params[:who]
25
+ erb :mixed, locals: { greeting: "from-explicit-locals" }
26
+ end
27
+ RB
28
+
29
+ def test_ivar_threading
30
+ res = get("/greet/alice/1")
31
+ assert_equal "200", res.code
32
+ assert_match(/hi, alice!/, res.body)
33
+ assert_match(/visited 1 times/, res.body)
34
+ assert_match(/welcome\./, res.body)
35
+ end
36
+
37
+ def test_ivar_int_to_s_coercion
38
+ # @count = params[:n] writes a string already, but the rewriter's
39
+ # `.to_s` wrap means the same code would still work if @count
40
+ # held a literal integer (which the template's <%= ... %> renders
41
+ # back to a string).
42
+ res = get("/greet/bob/3")
43
+ assert_equal "200", res.code
44
+ assert_match(/visited 3 times/, res.body)
45
+ refute_match(/welcome\./, res.body) # only renders for count == "1"
46
+ end
47
+
48
+ def test_locals_and_ivars_coexist
49
+ res = get("/mixed/charlie")
50
+ assert_equal "200", res.code
51
+ assert_match(/hello, charlie/, res.body)
52
+ assert_match(/locals greeting: from-explicit-locals/, res.body)
53
+ # The before-filter set @greeting; the explicit `locals: {...}`
54
+ # call doesn't shadow it because they're separate hashes in the
55
+ # template signature.
56
+ assert_match(/ivar greeting: filter-said-hi/, res.body)
57
+ end
58
+ end