syntropy 0.38.1 → 0.40.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +2 -0
- data/TODO.md +55 -144
- data/cmd/console.rb +2 -4
- data/cmd/new/template/config/production.rb +2 -1
- data/cmd/new.rb +1 -1
- data/cmd/serve.rb +29 -4
- data/cmd/test.rb +12 -4
- data/examples/agent/Dockerfile +25 -0
- data/examples/agent/Gemfile +3 -0
- data/examples/agent/README.md +40 -0
- data/examples/agent/TODO.md +5 -0
- data/examples/agent/app/_layout/default.rb +15 -0
- data/examples/agent/app/_lib/storage.rb +13 -0
- data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/agent/app/assets/agent.js +63 -0
- data/examples/agent/app/assets/minigfm.js +207 -0
- data/examples/agent/app/assets/model.js +45 -0
- data/examples/agent/app/assets/style.css +25 -0
- data/examples/agent/app/assets/tools.js +74 -0
- data/examples/agent/app/assets/ui.js +50 -0
- data/examples/agent/app/index.rb +19 -0
- data/examples/agent/app/test.rb +7 -0
- data/examples/agent/config/Caddyfile +5 -0
- data/examples/agent/config/development.rb +5 -0
- data/examples/agent/config/production.rb +5 -0
- data/examples/agent/config/test.rb +5 -0
- data/examples/agent/docker-compose.yml +71 -0
- data/examples/agent/response_example.json +108 -0
- data/examples/agent/test/test_app.rb +14 -0
- data/examples/blog/app/foo.rb +1 -0
- data/examples/blog/app/posts/[id]/edit.rb +3 -3
- data/examples/blog/app/posts/[id]/index.rb +8 -10
- data/examples/blog/app/posts/index.rb +6 -8
- data/examples/blog/app/posts/new.rb +3 -3
- data/examples/blog/config/production.rb +2 -1
- data/lib/syntropy/app.rb +12 -60
- data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
- data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
- data/lib/syntropy/controller_extensions.rb +1 -1
- data/lib/syntropy/dev_mode.rb +8 -3
- data/lib/syntropy/http/client.rb +17 -1
- data/lib/syntropy/http/client_connection.rb +16 -0
- data/lib/syntropy/http/io_extensions.rb +32 -4
- data/lib/syntropy/http/server.rb +55 -24
- data/lib/syntropy/http/server_connection.rb +72 -2
- data/lib/syntropy/logger.rb +8 -0
- data/lib/syntropy/markdown.rb +139 -11
- data/lib/syntropy/module_loader.rb +110 -68
- data/lib/syntropy/request/request_info.rb +8 -0
- data/lib/syntropy/request/response.rb +76 -6
- data/lib/syntropy/request/validation.rb +6 -0
- data/lib/syntropy/storage/connection_pool.rb +34 -0
- data/lib/syntropy/test.rb +14 -5
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +9 -2
- data/syntropy.gemspec +4 -4
- data/test/fixtures/app/_layout/default.rb +1 -1
- data/test/fixtures/app/_layout/kuku.rb +8 -0
- data/test/fixtures/app/_lib/circular/a.rb +2 -0
- data/test/fixtures/app/_lib/circular/b.rb +2 -0
- data/test/fixtures/app/_lib/circular/c.rb +2 -0
- data/test/fixtures/app/mod/concurrent.rb +9 -0
- data/test/test_app.rb +1 -1
- data/test/test_caching.rb +3 -3
- data/test/test_markdown.rb +268 -0
- data/test/test_module_loader.rb +32 -4
- data/test/test_request.rb +7 -0
- data/test/test_response.rb +30 -0
- data/test/test_schema.rb +1 -0
- metadata +40 -11
|
@@ -11,6 +11,10 @@ module Syntropy
|
|
|
11
11
|
module ResponseMethods
|
|
12
12
|
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
13
13
|
|
|
14
|
+
# Upgrades a request to the WebSocket protocol.
|
|
15
|
+
#
|
|
16
|
+
# @param custom_headers [Hash, nil] additional response headers
|
|
17
|
+
# @return [void]
|
|
14
18
|
def upgrade_to_websocket(custom_headers = nil)
|
|
15
19
|
key = "#{headers['sec-websocket-key']}#{WEBSOCKET_GUID}"
|
|
16
20
|
upgrade_headers = {
|
|
@@ -22,20 +26,42 @@ module Syntropy
|
|
|
22
26
|
adapter.websocket_connection(self)
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
# Responds with a redirect to the given URL. If a relative path is provided
|
|
30
|
+
# as the URL, the absolute URL path will be calculated relative to the
|
|
31
|
+
# current request's path.
|
|
32
|
+
#
|
|
33
|
+
# @param url [String] redirect URL
|
|
34
|
+
# @param status [Integer] HTTP status code
|
|
35
|
+
# @return [void]
|
|
25
36
|
def redirect(url, status = HTTP::FOUND)
|
|
37
|
+
url = File.expand_path(File.join(path, url)) if url =~ /^\./
|
|
26
38
|
respond(nil, ':status' => status, 'Location' => url)
|
|
27
39
|
end
|
|
28
40
|
|
|
41
|
+
# Redirects to the same URL, except with HTTPS.
|
|
42
|
+
#
|
|
43
|
+
# @param status [Integer] HTTP status code
|
|
44
|
+
# @return [void]
|
|
29
45
|
def redirect_to_https(status = HTTP::MOVED_PERMANENTLY)
|
|
30
46
|
secure_uri = "https://#{host}#{uri}"
|
|
31
47
|
redirect(secure_uri, status)
|
|
32
48
|
end
|
|
33
49
|
|
|
50
|
+
# Redirect to the same path and query, on a different host.
|
|
51
|
+
#
|
|
52
|
+
# @param new_host [String] new host name
|
|
53
|
+
# @param status [Integer] HTTP status code
|
|
54
|
+
# @return [void]
|
|
34
55
|
def redirect_to_host(new_host, status = HTTP::FOUND)
|
|
35
56
|
secure_uri = "//#{new_host}#{uri}"
|
|
36
57
|
redirect(secure_uri, status)
|
|
37
58
|
end
|
|
38
59
|
|
|
60
|
+
# Serves a static file as a response, with cache headers.
|
|
61
|
+
#
|
|
62
|
+
# @param path [String] file path
|
|
63
|
+
# @param opts [Hash] options
|
|
64
|
+
# @return [void]
|
|
39
65
|
def serve_file(path, opts = {})
|
|
40
66
|
full_path = file_full_path(path, opts)
|
|
41
67
|
stat = File.stat(full_path)
|
|
@@ -58,6 +84,12 @@ module Syntropy
|
|
|
58
84
|
respond(nil, ':status' => HTTP::NOT_FOUND)
|
|
59
85
|
end
|
|
60
86
|
|
|
87
|
+
# Validates a static file cache against the request cache headers. Returns
|
|
88
|
+
# true if cache headers are valid.
|
|
89
|
+
#
|
|
90
|
+
# @param etag [String] ETag
|
|
91
|
+
# @param last_modified [String] last-modified value
|
|
92
|
+
# @return [bool] are cache headers valid
|
|
61
93
|
def validate_static_file_cache(etag, last_modified)
|
|
62
94
|
if (none_match = headers['if-none-match'])
|
|
63
95
|
return true if none_match == etag
|
|
@@ -69,6 +101,11 @@ module Syntropy
|
|
|
69
101
|
false
|
|
70
102
|
end
|
|
71
103
|
|
|
104
|
+
# Returns a fully qualified file path based on the given options.
|
|
105
|
+
#
|
|
106
|
+
# @param path [String] file path
|
|
107
|
+
# @param opts [Hash] options hash
|
|
108
|
+
# @return [String] qualified file path
|
|
72
109
|
def file_full_path(path, opts)
|
|
73
110
|
if (base_path = opts[:base_path])
|
|
74
111
|
File.join(opts[:base_path], path)
|
|
@@ -77,10 +114,13 @@ module Syntropy
|
|
|
77
114
|
end
|
|
78
115
|
end
|
|
79
116
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
117
|
+
# Renders a static file response.
|
|
118
|
+
#
|
|
119
|
+
# @param path [String] file path
|
|
120
|
+
# @param etag [String] ETag
|
|
121
|
+
# @param last_modified [String] last-modified value
|
|
122
|
+
# @param opts [Hash] options
|
|
123
|
+
# @return [void]
|
|
84
124
|
def respond_with_static_file(path, etag, last_modified, opts)
|
|
85
125
|
cache_headers = (etag || last_modified) ? {
|
|
86
126
|
'etag' => etag,
|
|
@@ -90,14 +130,29 @@ module Syntropy
|
|
|
90
130
|
adapter.respond_with_static_file(self, path, opts, cache_headers)
|
|
91
131
|
end
|
|
92
132
|
|
|
133
|
+
# Set additional response headers.
|
|
134
|
+
#
|
|
135
|
+
# @param headers [Hash] response headers
|
|
136
|
+
# @return [void]
|
|
93
137
|
def set_response_headers(headers)
|
|
94
138
|
adapter.set_response_headers(headers)
|
|
95
139
|
end
|
|
96
140
|
|
|
97
|
-
|
|
98
|
-
|
|
141
|
+
# Sets a response cookie.
|
|
142
|
+
#
|
|
143
|
+
# @param key [String] cookie name
|
|
144
|
+
# @param value [String] cookie value
|
|
145
|
+
# @return [void]
|
|
146
|
+
def set_cookie(key, value)
|
|
147
|
+
adapter.set_cookie(key, value)
|
|
99
148
|
end
|
|
100
149
|
|
|
150
|
+
# Upgrades the connection to the given protocol, yielding the connection to
|
|
151
|
+
# the given block.
|
|
152
|
+
#
|
|
153
|
+
# @param protocol [String] upgraded protocol
|
|
154
|
+
# @param custom_headers [Hash, nil] additional response headers
|
|
155
|
+
# @return [void]
|
|
101
156
|
def upgrade(protocol, custom_headers = nil, &block)
|
|
102
157
|
upgrade_headers = {
|
|
103
158
|
':status' => HTTP::SWITCHING_PROTOCOLS,
|
|
@@ -168,6 +223,11 @@ module Syntropy
|
|
|
168
223
|
end
|
|
169
224
|
end
|
|
170
225
|
|
|
226
|
+
# Renders an HTML repsonse with HTML content type.
|
|
227
|
+
#
|
|
228
|
+
# @param html [String] HTML response body
|
|
229
|
+
# @param headers [Hash] additional response headers
|
|
230
|
+
# @return [void]
|
|
171
231
|
def respond_html(html, **headers)
|
|
172
232
|
respond(
|
|
173
233
|
html,
|
|
@@ -176,6 +236,11 @@ module Syntropy
|
|
|
176
236
|
)
|
|
177
237
|
end
|
|
178
238
|
|
|
239
|
+
# Renders a JSON repsonse with JSON content type.
|
|
240
|
+
#
|
|
241
|
+
# @param obj [any] obj to render into JSON
|
|
242
|
+
# @param headers [Hash] additional response headers
|
|
243
|
+
# @return [void]
|
|
179
244
|
def respond_json(obj, **headers)
|
|
180
245
|
respond(
|
|
181
246
|
JSON.dump(obj),
|
|
@@ -184,6 +249,11 @@ module Syntropy
|
|
|
184
249
|
)
|
|
185
250
|
end
|
|
186
251
|
|
|
252
|
+
# Renders a nicely-formatted JSON repsonse with JSON content type.
|
|
253
|
+
#
|
|
254
|
+
# @param obj [any] obj to render into JSON
|
|
255
|
+
# @param headers [Hash] additional response headers
|
|
256
|
+
# @return [void]
|
|
187
257
|
def json_pretty_response(obj, **headers)
|
|
188
258
|
respond(
|
|
189
259
|
JSON.pretty_generate(obj),
|
|
@@ -18,6 +18,12 @@ module Syntropy
|
|
|
18
18
|
raise Syntropy::Error.method_not_allowed
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Validates the request's content-type against the given accepted content
|
|
22
|
+
# types. If the content type does not match the given accepted list, raises
|
|
23
|
+
# an error.
|
|
24
|
+
#
|
|
25
|
+
# @param accepted [Array<String>] array of accepted content types
|
|
26
|
+
# @return [String] accepted content type
|
|
21
27
|
def validate_content_type(*accepted)
|
|
22
28
|
ct = content_type
|
|
23
29
|
return ct if accepted.include?(ct)
|
|
@@ -8,6 +8,12 @@ module Syntropy
|
|
|
8
8
|
class ConnectionPool
|
|
9
9
|
attr_reader :count
|
|
10
10
|
|
|
11
|
+
# Initializes the connection pool.
|
|
12
|
+
#
|
|
13
|
+
# @param machine [UringMachine] machine instance
|
|
14
|
+
# @param fn [String] database filename
|
|
15
|
+
# @param max_conn [Integer] maximum number of connections
|
|
16
|
+
# @return [void]
|
|
11
17
|
def initialize(machine, fn, max_conn)
|
|
12
18
|
@machine = machine
|
|
13
19
|
@fn = fn
|
|
@@ -17,6 +23,11 @@ module Syntropy
|
|
|
17
23
|
@key = :"connection_pool_#{object_id}"
|
|
18
24
|
end
|
|
19
25
|
|
|
26
|
+
# Checks out a database connection, passing it to the given block. This
|
|
27
|
+
# method is reentrant - called from the same fiber, it will yield the same
|
|
28
|
+
# database connection.
|
|
29
|
+
#
|
|
30
|
+
# @return [Extralite::Database] database connection
|
|
20
31
|
def with_db
|
|
21
32
|
if (db = Thread.current[@key])
|
|
22
33
|
@machine.snooze
|
|
@@ -33,14 +44,27 @@ module Syntropy
|
|
|
33
44
|
end
|
|
34
45
|
end
|
|
35
46
|
|
|
47
|
+
# Performs the given query by acquiring a database connection and running
|
|
48
|
+
# the query.
|
|
49
|
+
#
|
|
50
|
+
# @param sql [String] SQL query
|
|
51
|
+
# @return [Array<Hash>] result rows
|
|
36
52
|
def query(sql, *, **, &)
|
|
37
53
|
with_db { it.query(sql, *, **, &) }
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
# Executes the given query by acquiring a database connection and running
|
|
57
|
+
# the query.
|
|
58
|
+
#
|
|
59
|
+
# @param sql [String] SQL query
|
|
60
|
+
# @return [Integer] number of changed rows
|
|
40
61
|
def execute(sql, *, **)
|
|
41
62
|
with_db { it.execute(sql, *, **) }
|
|
42
63
|
end
|
|
43
64
|
|
|
65
|
+
# Closes the connection pool by removing all connections.
|
|
66
|
+
#
|
|
67
|
+
# @return [void]
|
|
44
68
|
def close
|
|
45
69
|
while @queue.count > 0
|
|
46
70
|
db = @machine.shift(@queue)
|
|
@@ -51,16 +75,26 @@ module Syntropy
|
|
|
51
75
|
|
|
52
76
|
private
|
|
53
77
|
|
|
78
|
+
# Checks out a connection from the pool.
|
|
79
|
+
#
|
|
80
|
+
# @return [Extralite::Database] database connection
|
|
54
81
|
def checkout
|
|
55
82
|
return make_db_instance if @queue.count == 0 && @count < @max_conn
|
|
56
83
|
|
|
57
84
|
@machine.shift(@queue)
|
|
58
85
|
end
|
|
59
86
|
|
|
87
|
+
# Checks in a connection to the pool.
|
|
88
|
+
#
|
|
89
|
+
# @param db [Extralite::Database] database connection
|
|
90
|
+
# @return [void]
|
|
60
91
|
def checkin(db)
|
|
61
92
|
@machine.push(@queue, db)
|
|
62
93
|
end
|
|
63
94
|
|
|
95
|
+
# Creates a database connection.
|
|
96
|
+
#
|
|
97
|
+
# @return [Extralite::Database] database connection
|
|
64
98
|
def make_db_instance
|
|
65
99
|
Extralite::Database.new(@fn, wal: true).tap do
|
|
66
100
|
@count += 1
|
data/lib/syntropy/test.rb
CHANGED
|
@@ -12,6 +12,14 @@ module Syntropy
|
|
|
12
12
|
HTTP = Syntropy::HTTP
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
|
+
def global_env=(env)
|
|
16
|
+
@@global_env = env
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def global_env
|
|
20
|
+
@@global_env
|
|
21
|
+
end
|
|
22
|
+
|
|
15
23
|
# Gets/sets app environment for tests
|
|
16
24
|
attr_accessor :env
|
|
17
25
|
end
|
|
@@ -22,15 +30,16 @@ module Syntropy
|
|
|
22
30
|
#
|
|
23
31
|
# @return [Hash] test app environment
|
|
24
32
|
def env
|
|
25
|
-
self.class
|
|
33
|
+
klass = self.class
|
|
34
|
+
klass.env || klass.global_env
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
# Loads and returns a module with the given reference.
|
|
29
38
|
#
|
|
30
39
|
# @param ref [String] module reference
|
|
31
40
|
# @return [any] module
|
|
32
|
-
def load_module(ref,
|
|
33
|
-
app.module_loader.load(ref,
|
|
41
|
+
def load_module(ref, raise_on_missing_export: true)
|
|
42
|
+
app.module_loader.load(ref, raise_on_missing_export:)
|
|
34
43
|
end
|
|
35
44
|
|
|
36
45
|
# Makes an HTTP request to the test app.
|
|
@@ -122,7 +131,7 @@ module Syntropy
|
|
|
122
131
|
#
|
|
123
132
|
# @return [void]
|
|
124
133
|
def setup
|
|
125
|
-
env = self.
|
|
134
|
+
env = self.env
|
|
126
135
|
raise 'Environment not set' if !env
|
|
127
136
|
|
|
128
137
|
Syntropy.load_config(env)
|
|
@@ -136,7 +145,7 @@ module Syntropy
|
|
|
136
145
|
)
|
|
137
146
|
@test_harness = Syntropy::TestHarness.new(@app)
|
|
138
147
|
|
|
139
|
-
@db = load_module('/_lib/storage',
|
|
148
|
+
@db = load_module('/_lib/storage', raise_on_missing_export: false)
|
|
140
149
|
@db&.migrate! if @db.respond_to?(:migrate!)
|
|
141
150
|
end
|
|
142
151
|
|
data/lib/syntropy/version.rb
CHANGED
data/lib/syntropy.rb
CHANGED
|
@@ -72,15 +72,22 @@ module Syntropy
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Loads the app config module based on the environment's config root and
|
|
76
|
+
# mode: (test, dev, production). The module's exported value is assigned to
|
|
77
|
+
# env[:config].
|
|
78
|
+
#
|
|
79
|
+
# @param env [Hash] app environment
|
|
80
|
+
# @return [void]
|
|
75
81
|
def load_config(env)
|
|
76
82
|
return if !env[:config_root]
|
|
77
83
|
|
|
78
84
|
loader_env = env.merge(
|
|
79
85
|
app_root: env[:config_root],
|
|
80
|
-
logger: nil
|
|
86
|
+
logger: nil,
|
|
87
|
+
machine: UM.new(size: 8)
|
|
81
88
|
)
|
|
82
89
|
loader = ModuleLoader.new(loader_env)
|
|
83
|
-
if (config = loader.load(env[:mode],
|
|
90
|
+
if (config = loader.load(env[:mode], raise_on_missing_export: false))
|
|
84
91
|
env[:config] = config
|
|
85
92
|
end
|
|
86
93
|
end
|
data/syntropy.gemspec
CHANGED
|
@@ -21,9 +21,9 @@ Gem::Specification.new do |s|
|
|
|
21
21
|
s.required_ruby_version = '>= 3.4'
|
|
22
22
|
s.executables = ['syntropy']
|
|
23
23
|
|
|
24
|
-
s.add_dependency 'extralite', '~>
|
|
25
|
-
s.add_dependency 'papercraft', '~>3.2.
|
|
26
|
-
s.add_dependency 'uringmachine', '~>1.0.
|
|
24
|
+
s.add_dependency 'extralite', '~>3.0.1'
|
|
25
|
+
s.add_dependency 'papercraft', '~>3.2.2'
|
|
26
|
+
s.add_dependency 'uringmachine', '~>1.0.4'
|
|
27
27
|
|
|
28
28
|
s.add_dependency 'json'
|
|
29
29
|
s.add_dependency 'base64'
|
|
@@ -31,6 +31,6 @@ Gem::Specification.new do |s|
|
|
|
31
31
|
s.add_dependency 'irb'
|
|
32
32
|
s.add_dependency 'minitest'
|
|
33
33
|
|
|
34
|
-
s.add_development_dependency 'rake'
|
|
34
|
+
s.add_development_dependency 'rake'
|
|
35
35
|
s.add_development_dependency 'solargraph'
|
|
36
36
|
end
|
data/test/test_app.rb
CHANGED
|
@@ -100,7 +100,7 @@ class AppTest < Minitest::Test
|
|
|
100
100
|
assert_equal 'About', req.response_body.chomp
|
|
101
101
|
|
|
102
102
|
req = @test_harness.request(':method' => 'GET', ':path' => '/test/about/foo')
|
|
103
|
-
assert_equal '<!DOCTYPE html><html><head
|
|
103
|
+
assert_equal '<!DOCTYPE html><html><head></head><body><p>Hello from Markdown</p></body></html>', req.response_body.gsub(/\n/, '')
|
|
104
104
|
|
|
105
105
|
req = @test_harness.request(':method' => 'HEAD', ':path' => '/test/about/foo')
|
|
106
106
|
assert_nil req.response_body
|
data/test/test_caching.rb
CHANGED
|
@@ -75,7 +75,7 @@ class CachingTest < Minitest::Test
|
|
|
75
75
|
@adapter.serve_request
|
|
76
76
|
response = read_client_side
|
|
77
77
|
|
|
78
|
-
expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=
|
|
78
|
+
expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
|
|
79
79
|
assert_equal expected, response
|
|
80
80
|
end
|
|
81
81
|
|
|
@@ -92,7 +92,7 @@ class CachingTest < Minitest::Test
|
|
|
92
92
|
@adapter.serve_request
|
|
93
93
|
response = read_client_side
|
|
94
94
|
|
|
95
|
-
expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=
|
|
95
|
+
expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
|
|
96
96
|
assert_equal expected, response
|
|
97
97
|
|
|
98
98
|
# good etag
|
|
@@ -118,7 +118,7 @@ class CachingTest < Minitest::Test
|
|
|
118
118
|
@adapter.serve_request
|
|
119
119
|
response = read_client_side
|
|
120
120
|
|
|
121
|
-
expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=
|
|
121
|
+
expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
|
|
122
122
|
assert_equal expected, response
|
|
123
123
|
|
|
124
124
|
# good etag
|