tina4ruby 3.12.13 → 3.13.1
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/lib/tina4/api.rb +21 -1
- data/lib/tina4/auth.rb +14 -8
- data/lib/tina4/database.rb +36 -0
- data/lib/tina4/graphql.rb +94 -0
- data/lib/tina4/service.rb +60 -0
- data/lib/tina4/service_runner.rb +35 -0
- data/lib/tina4/test.rb +179 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb13055e35412e98279f173f9cda3b6dc3e5a2c6766db1c2439b74e9de3354e2
|
|
4
|
+
data.tar.gz: 907a9ba2b3a732927dfd965cb41a212fcb4e245688fa6fab2ae805728e71489f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b261fd27af0fed8f591599c2acf376c0333286910d302773e228fe92cae1e24201260a240081f26bb9abba86f731e9c7790d8c1f26b86dff857d1a87d0b938b0
|
|
7
|
+
data.tar.gz: 821cb969b8b9822b39d9b6b8d0e2b0ca4a74c7a35febcb277b6e8b119df38dfb09af5acaf88fbcda1b3793a44f4477c636bc5b7223a14214029dbbd8eb9f4cce
|
data/lib/tina4/api.rb
CHANGED
|
@@ -8,13 +8,33 @@ module Tina4
|
|
|
8
8
|
class API
|
|
9
9
|
attr_reader :base_url, :headers
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# 3.13.1: added ergonomic kwargs (bearer_token, username, password,
|
|
12
|
+
# verify_ssl) so callers no longer need three follow-up setter calls.
|
|
13
|
+
# Cross-framework parity with the Python tina4_python.api.Api kwargs.
|
|
14
|
+
#
|
|
15
|
+
# api = Tina4::API.new("https://api.example.com", bearer_token: "sk-abc")
|
|
16
|
+
# api = Tina4::API.new("https://api.example.com", username: "u", password: "p")
|
|
17
|
+
# api = Tina4::API.new("https://api.example.com", headers: {"X-Tenant" => "acme"})
|
|
18
|
+
# api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
|
|
19
|
+
#
|
|
20
|
+
# Bearer wins over basic-auth when both are passed.
|
|
21
|
+
def initialize(base_url, headers: {}, timeout: 30,
|
|
22
|
+
bearer_token: nil, username: nil, password: nil,
|
|
23
|
+
verify_ssl: nil)
|
|
12
24
|
@base_url = base_url.chomp("/")
|
|
13
25
|
@headers = {
|
|
14
26
|
"Content-Type" => "application/json",
|
|
15
27
|
"Accept" => "application/json"
|
|
16
28
|
}.merge(headers)
|
|
17
29
|
@timeout = timeout
|
|
30
|
+
@verify_ssl = verify_ssl
|
|
31
|
+
|
|
32
|
+
# Bearer wins over basic-auth when both passed
|
|
33
|
+
if bearer_token
|
|
34
|
+
set_bearer_token(bearer_token)
|
|
35
|
+
elsif username && password
|
|
36
|
+
set_basic_auth(username, password)
|
|
37
|
+
end
|
|
18
38
|
end
|
|
19
39
|
|
|
20
40
|
def get(path, params: {})
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -109,19 +109,25 @@ module Tina4
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
# Verify a JWT signature + expiry.
|
|
113
|
+
#
|
|
114
|
+
# 3.13.0: return type changed from `Boolean` to `Hash | nil`. The
|
|
115
|
+
# decoded payload is returned on success, nil on failure. Matches
|
|
116
|
+
# firebase/jwt-ruby and Python's Auth.valid_token in 3.13.0.
|
|
117
|
+
#
|
|
118
|
+
# Legacy `if Tina4::Auth.valid_token(t)` patterns keep working
|
|
119
|
+
# because a non-empty Hash is truthy and nil is falsy.
|
|
120
|
+
def valid_token(token)
|
|
113
121
|
if use_hmac?
|
|
114
|
-
|
|
122
|
+
hmac_decode(token, hmac_secret) # returns Hash payload or nil
|
|
115
123
|
else
|
|
116
124
|
ensure_keys
|
|
117
125
|
require "jwt"
|
|
118
|
-
JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
119
|
-
|
|
126
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
127
|
+
decoded[0] # firebase/jwt-ruby returns [payload, header]
|
|
120
128
|
end
|
|
121
|
-
rescue JWT::ExpiredSignature
|
|
122
|
-
|
|
123
|
-
rescue JWT::DecodeError
|
|
124
|
-
false
|
|
129
|
+
rescue JWT::ExpiredSignature, JWT::DecodeError
|
|
130
|
+
nil
|
|
125
131
|
end
|
|
126
132
|
|
|
127
133
|
def valid_token_detail(token)
|
data/lib/tina4/database.rb
CHANGED
|
@@ -101,6 +101,29 @@ module Tina4
|
|
|
101
101
|
pool: pool)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# Open a database connection — convention name matching SQLAlchemy
|
|
105
|
+
# engine.connect() and the cross-framework Database.get_connection()
|
|
106
|
+
# surface shipped in 3.13.x.
|
|
107
|
+
#
|
|
108
|
+
# The first argument may be either a URL (containing `://` or `sqlite:`)
|
|
109
|
+
# or an env-var name. Falls back to in-memory SQLite when no URL
|
|
110
|
+
# resolves — matches Python tina4_python's default behaviour.
|
|
111
|
+
#
|
|
112
|
+
# db = Tina4::Database.get_connection # from TINA4_DATABASE_URL
|
|
113
|
+
# db = Tina4::Database.get_connection("sqlite::memory:") # explicit URL
|
|
114
|
+
# db = Tina4::Database.get_connection("postgres://...", username: "u", password: "p")
|
|
115
|
+
def self.get_connection(url_or_env_key = "TINA4_DATABASE_URL", username: nil, password: nil, pool: nil)
|
|
116
|
+
if url_or_env_key.include?("://") || url_or_env_key.start_with?("sqlite:")
|
|
117
|
+
return new(url_or_env_key, username: username, password: password, pool: pool)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
db = from_env(env_key: url_or_env_key, pool: pool)
|
|
121
|
+
return db if db
|
|
122
|
+
|
|
123
|
+
# Fallback: in-memory SQLite — matches Python parity.
|
|
124
|
+
new("sqlite::memory:", username: username, password: password, pool: pool)
|
|
125
|
+
end
|
|
126
|
+
|
|
104
127
|
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil)
|
|
105
128
|
@connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
|
|
106
129
|
@username = username || ENV["TINA4_DATABASE_USERNAME"]
|
|
@@ -213,6 +236,19 @@ module Tina4
|
|
|
213
236
|
end
|
|
214
237
|
end
|
|
215
238
|
|
|
239
|
+
# Fetch rows and return the records array directly.
|
|
240
|
+
#
|
|
241
|
+
# Symmetric with fetch_one. Cross-framework parity with Python
|
|
242
|
+
# db.fetch_all() / PHP $db->fetchAll() / Node db.fetchAll().
|
|
243
|
+
#
|
|
244
|
+
# rows = db.fetch_all("SELECT * FROM users WHERE active = ?", [1])
|
|
245
|
+
# rows.each { |row| puts row["name"] }
|
|
246
|
+
#
|
|
247
|
+
# Returns [] (not nil) when no rows match.
|
|
248
|
+
def fetch_all(sql, params = [], limit: 100, offset: nil)
|
|
249
|
+
fetch(sql, params, limit: limit, offset: offset).records
|
|
250
|
+
end
|
|
251
|
+
|
|
216
252
|
def fetch(sql, params = [], limit: 100, offset: nil)
|
|
217
253
|
offset ||= 0
|
|
218
254
|
drv = current_driver
|
data/lib/tina4/graphql.rb
CHANGED
|
@@ -853,9 +853,103 @@ module Tina4
|
|
|
853
853
|
!%w[false 0 no off].include?(val)
|
|
854
854
|
end
|
|
855
855
|
|
|
856
|
+
# ── Class-level resolver registry — 3.13.1 ────────────────────────
|
|
857
|
+
#
|
|
858
|
+
# Resolvers registered via Tina4::GraphQL.resolve("Type", "field")
|
|
859
|
+
# accumulate here BEFORE any GraphQL instance exists. When `new` runs,
|
|
860
|
+
# the instance drains the registry into its schema. This is what makes
|
|
861
|
+
# the documented decorator-style pattern work at app-startup time:
|
|
862
|
+
# modules `Tina4::GraphQL.resolve(...)` at load time and register
|
|
863
|
+
# before the singleton is even constructed. Cross-framework parity
|
|
864
|
+
# with Python @GraphQL.resolve and PHP GraphQL::resolve.
|
|
865
|
+
@class_resolvers = {}
|
|
866
|
+
@default_instance = nil
|
|
867
|
+
|
|
868
|
+
class << self
|
|
869
|
+
attr_accessor :default_instance
|
|
870
|
+
|
|
871
|
+
def class_resolvers
|
|
872
|
+
@class_resolvers ||= {}
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# Decorator-style resolver registration.
|
|
876
|
+
#
|
|
877
|
+
# Tina4::GraphQL.resolve("Query", "products") do |root, args, ctx|
|
|
878
|
+
# Product.all
|
|
879
|
+
# end
|
|
880
|
+
#
|
|
881
|
+
# Tina4::GraphQL.resolve("Mutation", "createProduct") do |root, args, ctx|
|
|
882
|
+
# Product.create(args["input"])
|
|
883
|
+
# end
|
|
884
|
+
#
|
|
885
|
+
# Tina4::GraphQL.resolve("Product", "reviews") do |product, args, ctx|
|
|
886
|
+
# Review.where("product_id = ?", [product["id"]])
|
|
887
|
+
# end
|
|
888
|
+
#
|
|
889
|
+
# Resolvers registered before any GraphQL instance accumulate in a
|
|
890
|
+
# class-level registry. new GraphQL drains them into its schema.
|
|
891
|
+
# Resolvers registered after .default_instance is set wire into
|
|
892
|
+
# the live schema immediately.
|
|
893
|
+
def resolve(type_name, field_name, &block)
|
|
894
|
+
class_resolvers[type_name] ||= {}
|
|
895
|
+
class_resolvers[type_name][field_name] = block
|
|
896
|
+
|
|
897
|
+
# If a default instance is active, attach immediately so post-startup
|
|
898
|
+
# registrations take effect without re-instantiation.
|
|
899
|
+
if @default_instance
|
|
900
|
+
@default_instance.send(:attach_resolver, type_name, field_name, block)
|
|
901
|
+
end
|
|
902
|
+
block
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Test-only — clear the class-level registry. Used by parity tests
|
|
906
|
+
# to avoid bleed-over between cases.
|
|
907
|
+
def _clear_class_resolvers!
|
|
908
|
+
@class_resolvers = {}
|
|
909
|
+
@default_instance = nil
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
856
913
|
def initialize(schema = nil)
|
|
857
914
|
@schema = schema || GraphQLSchema.new
|
|
858
915
|
@executor = GraphQLExecutor.new(@schema)
|
|
916
|
+
@field_resolvers = {}
|
|
917
|
+
|
|
918
|
+
# Drain any resolvers registered via the class-level GraphQL.resolve()
|
|
919
|
+
# BEFORE this instance was constructed.
|
|
920
|
+
self.class.class_resolvers.each do |type_name, fields|
|
|
921
|
+
fields.each do |field_name, resolver|
|
|
922
|
+
attach_resolver(type_name, field_name, resolver)
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# Wire a single resolver into the live schema.
|
|
928
|
+
def attach_resolver(type_name, field_name, resolver)
|
|
929
|
+
if type_name == "Query"
|
|
930
|
+
existing = @schema.queries[field_name] || {}
|
|
931
|
+
existing[:resolve] = resolver
|
|
932
|
+
existing[:args] ||= {}
|
|
933
|
+
existing[:type] ||= "String"
|
|
934
|
+
@schema.queries[field_name] = existing
|
|
935
|
+
elsif type_name == "Mutation"
|
|
936
|
+
existing = @schema.mutations[field_name] || {}
|
|
937
|
+
existing[:resolve] = resolver
|
|
938
|
+
existing[:args] ||= {}
|
|
939
|
+
existing[:type] ||= "String"
|
|
940
|
+
@schema.mutations[field_name] = existing
|
|
941
|
+
else
|
|
942
|
+
# Object-type field resolver — stash for the executor.
|
|
943
|
+
@field_resolvers[type_name] ||= {}
|
|
944
|
+
@field_resolvers[type_name][field_name] = resolver
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
private :attach_resolver
|
|
948
|
+
|
|
949
|
+
# Get the field resolver registered for an object type, if any.
|
|
950
|
+
# Used by the executor during nested field resolution.
|
|
951
|
+
def field_resolver(type_name, field_name)
|
|
952
|
+
@field_resolvers.dig(type_name, field_name)
|
|
859
953
|
end
|
|
860
954
|
|
|
861
955
|
# Execute a query string directly
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 — The Intelligent Native Application 4ramework
|
|
4
|
+
# Copyright 2007 - current Tina4
|
|
5
|
+
# License: MIT https://opensource.org/licenses/MIT
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
# Base class for class-based background services managed by
|
|
9
|
+
# {Tina4::ServiceRunner}. Cross-framework parity with PHP `Tina4\Service`
|
|
10
|
+
# and the same shape the documentation has long taught.
|
|
11
|
+
#
|
|
12
|
+
# class EmailQueueWorker < Tina4::Service
|
|
13
|
+
# def run
|
|
14
|
+
# until should_stop?
|
|
15
|
+
# process_next_job
|
|
16
|
+
# sleep 1
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Tina4::ServiceRunner.register_service("emails", EmailQueueWorker.new)
|
|
22
|
+
# Tina4::ServiceRunner.start
|
|
23
|
+
#
|
|
24
|
+
# Subclasses MUST override #run. Optionally override #stop for custom
|
|
25
|
+
# shutdown behaviour but always call `super` so the internal flag
|
|
26
|
+
# gets set — the default #should_stop? reads from it.
|
|
27
|
+
class Service
|
|
28
|
+
def initialize
|
|
29
|
+
@running = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Main work loop — subclasses MUST override.
|
|
33
|
+
def run
|
|
34
|
+
raise NotImplementedError, "#{self.class}#run must be implemented by the subclass"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Signal this service to stop. The next `should_stop?` check returns true.
|
|
38
|
+
def stop
|
|
39
|
+
@running = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns true once #stop has been called. Use inside #run loops as
|
|
43
|
+
# the exit condition:
|
|
44
|
+
#
|
|
45
|
+
# def run
|
|
46
|
+
# until should_stop?
|
|
47
|
+
# # do work
|
|
48
|
+
# end
|
|
49
|
+
# end
|
|
50
|
+
def should_stop?
|
|
51
|
+
!@running
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Return a callable that ServiceRunner can register. Used by
|
|
55
|
+
# ServiceRunner.register_service under the hood.
|
|
56
|
+
def to_proc
|
|
57
|
+
method(:run).to_proc
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/tina4/service_runner.rb
CHANGED
|
@@ -48,6 +48,41 @@ module Tina4
|
|
|
48
48
|
Tina4::Log.debug("Service registered: #{name}")
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Register a class-based service (subclass of {Tina4::Service}) by name.
|
|
52
|
+
#
|
|
53
|
+
# Wraps the Service's #run method as the handler callable that
|
|
54
|
+
# ServiceRunner.start invokes. The service's #stop is also wired
|
|
55
|
+
# up so ServiceRunner.stop(name) shuts it down cleanly.
|
|
56
|
+
#
|
|
57
|
+
# class EmailQueueWorker < Tina4::Service
|
|
58
|
+
# def run
|
|
59
|
+
# until should_stop?
|
|
60
|
+
# # process work
|
|
61
|
+
# end
|
|
62
|
+
# end
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# Tina4::ServiceRunner.register_service("emails", EmailQueueWorker.new)
|
|
66
|
+
# Tina4::ServiceRunner.start
|
|
67
|
+
#
|
|
68
|
+
# Default options set daemon: true because Service subclasses manage
|
|
69
|
+
# their own loop inside #run. Override via `options`.
|
|
70
|
+
def register_service(name, service, options = {})
|
|
71
|
+
raise ArgumentError, "service must be a Tina4::Service instance" unless service.is_a?(Tina4::Service)
|
|
72
|
+
|
|
73
|
+
options = { daemon: true }.merge(options)
|
|
74
|
+
callable = service.method(:run)
|
|
75
|
+
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
@registry[name.to_s] = {
|
|
78
|
+
handler: callable,
|
|
79
|
+
options: options,
|
|
80
|
+
instance: service,
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
Tina4::Log.debug("Service registered (class-based): #{name}")
|
|
84
|
+
end
|
|
85
|
+
|
|
51
86
|
# Auto-discover service files from a directory.
|
|
52
87
|
# Each file should call Tina4.service or Tina4::ServiceRunner.register.
|
|
53
88
|
def discover(service_dir = nil)
|
data/lib/tina4/test.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 — The Intelligent Native Application 4ramework
|
|
4
|
+
# Copyright 2007 - current Tina4
|
|
5
|
+
# License: MIT https://opensource.org/licenses/MIT
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
# Tina4 xUnit-style test base class — class-based test suites with HTTP
|
|
9
|
+
# helpers and positional assertions. Zero external dependencies.
|
|
10
|
+
#
|
|
11
|
+
# Documentation chapter 18 has long described:
|
|
12
|
+
#
|
|
13
|
+
# class UserApiTest < Tina4::Test
|
|
14
|
+
# def test_health
|
|
15
|
+
# resp = get("/health")
|
|
16
|
+
# assert_equal_value(resp.status, 200)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Until 3.13.0 this class did not exist — examples crashed with
|
|
21
|
+
# "uninitialized constant Tina4::Test". Ruby parity of the
|
|
22
|
+
# Python `tina4_python.test.Test` and PHP `Tina4\Test` classes.
|
|
23
|
+
#
|
|
24
|
+
# The class has a built-in runner (no Minitest/RSpec required):
|
|
25
|
+
#
|
|
26
|
+
# results = Tina4::Test.run_all # discovers all subclasses
|
|
27
|
+
# # => { passed: 12, failed: 0, errors: 0, details: [...] }
|
|
28
|
+
#
|
|
29
|
+
# Or run a single suite class:
|
|
30
|
+
#
|
|
31
|
+
# UserApiTest.run!
|
|
32
|
+
#
|
|
33
|
+
# HTTP helpers (get/post/put/patch/delete) delegate to TestClient.
|
|
34
|
+
# Positional assertions match the Python (actual, expected, message)
|
|
35
|
+
# shape used throughout the cross-framework docs.
|
|
36
|
+
class Test
|
|
37
|
+
# Class-level registry so run_all can discover every subclass without
|
|
38
|
+
# filesystem scanning.
|
|
39
|
+
@subclasses = []
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
attr_reader :subclasses
|
|
43
|
+
|
|
44
|
+
def inherited(subclass)
|
|
45
|
+
super
|
|
46
|
+
Test.subclasses << subclass
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Run every test method (`test_*`) on the calling subclass. Returns
|
|
50
|
+
# a hash with passed/failed/errors counts and per-test details.
|
|
51
|
+
def run!
|
|
52
|
+
instance_methods(false).grep(/\Atest_/).sort.each_with_object(
|
|
53
|
+
{ passed: 0, failed: 0, errors: 0, details: [] }
|
|
54
|
+
) do |method, results|
|
|
55
|
+
suite = new
|
|
56
|
+
begin
|
|
57
|
+
suite.send(:set_up)
|
|
58
|
+
suite.send(method)
|
|
59
|
+
suite.send(:tear_down)
|
|
60
|
+
results[:passed] += 1
|
|
61
|
+
results[:details] << { suite: name, test: method, status: "passed" }
|
|
62
|
+
rescue AssertionError => e
|
|
63
|
+
results[:failed] += 1
|
|
64
|
+
results[:details] << { suite: name, test: method, status: "failed", message: e.message }
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
results[:errors] += 1
|
|
67
|
+
results[:details] << { suite: name, test: method, status: "error", message: "#{e.class}: #{e.message}" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Discover and run every Tina4::Test subclass.
|
|
73
|
+
def run_all(quiet: false)
|
|
74
|
+
results = { passed: 0, failed: 0, errors: 0, details: [] }
|
|
75
|
+
Test.subclasses.each do |klass|
|
|
76
|
+
out = klass.run!
|
|
77
|
+
results[:passed] += out[:passed]
|
|
78
|
+
results[:failed] += out[:failed]
|
|
79
|
+
results[:errors] += out[:errors]
|
|
80
|
+
results[:details].concat(out[:details])
|
|
81
|
+
end
|
|
82
|
+
unless quiet
|
|
83
|
+
puts "Tina4 Test results: #{results[:passed]} passed, #{results[:failed]} failed, #{results[:errors]} errors"
|
|
84
|
+
end
|
|
85
|
+
results
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Lifecycle hooks ──────────────────────────────────────────────
|
|
90
|
+
# snake_case Tina4 idiom; override in subclasses.
|
|
91
|
+
|
|
92
|
+
def set_up
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def tear_down
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ── HTTP test client (lazy) ───────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def test_client
|
|
101
|
+
@test_client ||= Tina4::TestClient.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def get(path, headers: nil)
|
|
105
|
+
test_client.get(path, headers: headers)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def post(path, json: nil, body: nil, headers: nil)
|
|
109
|
+
test_client.post(path, json: json, body: body, headers: headers)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def put(path, json: nil, body: nil, headers: nil)
|
|
113
|
+
test_client.put(path, json: json, body: body, headers: headers)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def patch(path, json: nil, body: nil, headers: nil)
|
|
117
|
+
test_client.patch(path, json: json, body: body, headers: headers)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def delete(path, headers: nil)
|
|
121
|
+
test_client.delete(path, headers: headers)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ── Positional assertions — (actual, expected, message) shape ─────
|
|
125
|
+
|
|
126
|
+
def assert_equal_value(actual, expected, message = nil)
|
|
127
|
+
return if actual == expected
|
|
128
|
+
|
|
129
|
+
raise AssertionError, message || "Expected #{expected.inspect}, got #{actual.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def assert_not_equal_value(actual, expected, message = nil)
|
|
133
|
+
return unless actual == expected
|
|
134
|
+
|
|
135
|
+
raise AssertionError, message || "Expected #{actual.inspect} != #{expected.inspect}, but they are equal"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def assert_true(value, message = nil)
|
|
139
|
+
return if value
|
|
140
|
+
|
|
141
|
+
raise AssertionError, message || "Expected truthy, got #{value.inspect}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def assert_false(value, message = nil)
|
|
145
|
+
return unless value
|
|
146
|
+
|
|
147
|
+
raise AssertionError, message || "Expected falsy, got #{value.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def assert_nil_value(value, message = nil)
|
|
151
|
+
return if value.nil?
|
|
152
|
+
|
|
153
|
+
raise AssertionError, message || "Expected nil, got #{value.inspect}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def assert_not_nil_value(value, message = nil)
|
|
157
|
+
return unless value.nil?
|
|
158
|
+
|
|
159
|
+
raise AssertionError, message || "Expected non-nil, got nil"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def assert_raises(expected_class, message = nil)
|
|
163
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
yield
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
return if e.is_a?(expected_class)
|
|
169
|
+
|
|
170
|
+
raise AssertionError,
|
|
171
|
+
message || "Expected #{expected_class}, got #{e.class}: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
raise AssertionError, message || "Expected #{expected_class} to be raised, but nothing was"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# AssertionError raised by Tina4::Test assertion helpers on failure.
|
|
178
|
+
class AssertionError < StandardError; end
|
|
179
|
+
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -39,6 +39,7 @@ require_relative "tina4/background"
|
|
|
39
39
|
require_relative "tina4/localization"
|
|
40
40
|
require_relative "tina4/container"
|
|
41
41
|
require_relative "tina4/queue"
|
|
42
|
+
require_relative "tina4/service"
|
|
42
43
|
require_relative "tina4/service_runner"
|
|
43
44
|
require_relative "tina4/events"
|
|
44
45
|
require_relative "tina4/plan"
|
|
@@ -54,6 +55,7 @@ require_relative "tina4/response_cache"
|
|
|
54
55
|
require_relative "tina4/html_element"
|
|
55
56
|
require_relative "tina4/error_overlay"
|
|
56
57
|
require_relative "tina4/test_client"
|
|
58
|
+
require_relative "tina4/test"
|
|
57
59
|
require_relative "tina4/docs"
|
|
58
60
|
require_relative "tina4/mcp"
|
|
59
61
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.13.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -382,6 +382,7 @@ files:
|
|
|
382
382
|
- lib/tina4/scss/tina4css/tina4.scss
|
|
383
383
|
- lib/tina4/scss_compiler.rb
|
|
384
384
|
- lib/tina4/seeder.rb
|
|
385
|
+
- lib/tina4/service.rb
|
|
385
386
|
- lib/tina4/service_runner.rb
|
|
386
387
|
- lib/tina4/session.rb
|
|
387
388
|
- lib/tina4/session_handlers/database_handler.rb
|
|
@@ -402,6 +403,7 @@ files:
|
|
|
402
403
|
- lib/tina4/templates/errors/502.twig
|
|
403
404
|
- lib/tina4/templates/errors/503.twig
|
|
404
405
|
- lib/tina4/templates/errors/base.twig
|
|
406
|
+
- lib/tina4/test.rb
|
|
405
407
|
- lib/tina4/test_client.rb
|
|
406
408
|
- lib/tina4/testing.rb
|
|
407
409
|
- lib/tina4/validator.rb
|