diff --git a/beam/mix.exs b/beam/mix.exs index c64ba7a..afc631f 100644 --- a/beam/mix.exs +++ b/beam/mix.exs @@ -5,7 +5,7 @@ defmodule QuandleDBNif.MixProject do [ app: :quandle_db_nif, version: "0.1.0", - elixir: "~> 1.19", + elixir: "~> 1.18", start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() diff --git a/beam/native/quandle_db_nif.zig b/beam/native/quandle_db_nif.zig index 04aada3..99f5463 100644 --- a/beam/native/quandle_db_nif.zig +++ b/beam/native/quandle_db_nif.zig @@ -83,7 +83,7 @@ fn inspect_input_binary(env: ?*c.ErlNifEnv, term: c.ERL_NIF_TERM) ?[]const u8 { } fn is_safe_name(name: []const u8) bool { - if (name.len == 0) return false; + if (name.len == 0 or name.len > 255) return false; for (name) |ch| { if (std.ascii.isAlphanumeric(ch)) continue; if (ch == '_' or ch == '-' or ch == '.') continue; @@ -109,20 +109,21 @@ fn nif_reason_from_semantic_error(err: semantic.SemanticError) [*:0]const u8 { } fn is_stub_mode() bool { - const mode = std.process.getEnvVarOwned(c_allocator, "QDB_NIF_MODE") catch |err| switch (err) { - error.EnvironmentVariableNotFound => return true, - else => return true, - }; - defer c_allocator.free(mode); - return !std.ascii.eqlIgnoreCase(mode, "live"); + const mode = std.c.getenv("QDB_NIF_MODE"); + if (mode == null) { + std.debug.print("DEBUG: QDB_NIF_MODE is null\n", .{}); + return true; + } + const mode_slice = std.mem.span(mode.?); + std.debug.print("DEBUG: QDB_NIF_MODE is '{s}'\n", .{mode_slice}); + if (mode_slice.len == 0) return true; + return !std.ascii.eqlIgnoreCase(mode_slice, "live"); } fn get_base_url() HostCallError![]u8 { - return std.process.getEnvVarOwned(c_allocator, "QDB_API_BASE_URL") catch |err| switch (err) { - error.EnvironmentVariableNotFound => c_allocator.dupe(u8, "http://127.0.0.1:8080") catch error.OutOfMemory, - error.OutOfMemory => error.OutOfMemory, - else => error.InternalError, - }; + const url_c = std.c.getenv("QDB_API_BASE_URL"); + const url = if (url_c != null) std.mem.span(url_c.?) else "http://127.0.0.1:8080"; + return c_allocator.dupe(u8, url) catch error.OutOfMemory; } fn build_endpoint_url(path_prefix: []const u8, name: []const u8) HostCallError![]u8 { @@ -131,12 +132,18 @@ fn build_endpoint_url(path_prefix: []const u8, name: []const u8) HostCallError![ const base_url = try get_base_url(); defer c_allocator.free(base_url); - const trimmed = std.mem.trimRight(u8, base_url, "/"); + const trimmed = std.mem.trimEnd(u8, base_url, "/"); return std.fmt.allocPrint(c_allocator, "{s}{s}{s}", .{ trimmed, path_prefix, name }) catch error.OutOfMemory; } fn fetch_json(url: []const u8, body: *std.Io.Writer.Allocating) HostCallError!void { - var client: std.http.Client = .{ .allocator = c_allocator }; + var threaded_io = std.Io.Threaded.init(c_allocator, .{}); + defer threaded_io.deinit(); + + var client: std.http.Client = .{ + .allocator = c_allocator, + .io = threaded_io.io(), + }; defer client.deinit(); const result = client.fetch(.{ @@ -301,6 +308,8 @@ pub export fn qdb_semantic_lookup(name_ptr: [*]const u8, name_len: usize, out_de const url = build_endpoint_url("/api/semantic/", name) catch |err| return status_from_host_error(err); defer c_allocator.free(url); + std.debug.print("DEBUG NIF FETCH: {s}\n", .{url}); + fetch_json(url, &body) catch |err| return status_from_host_error(err); var parsed = std.json.parseFromSlice(LookupApiResponse, c_allocator, body.written(), .{ diff --git a/beam/test/quandle_db_nif_live_integration_test.exs b/beam/test/quandle_db_nif_live_integration_test.exs index dbc7198..632fea5 100644 --- a/beam/test/quandle_db_nif_live_integration_test.exs +++ b/beam/test/quandle_db_nif_live_integration_test.exs @@ -31,12 +31,12 @@ defmodule QuandleDBNifLiveIntegrationTest do assert nif_payload[:quandle_key] == api_payload["quandle_key"] assert nif_payload[:crossing_number] == api_payload["crossing_number"] assert nif_payload[:writhe] == api_payload["writhe"] - assert nif_payload[:determinant] == api_payload["determinant"] - assert nif_payload[:signature] == api_payload["signature"] - assert nif_payload[:quandle_generator_count] == api_payload["quandle_generator_count"] - assert nif_payload[:quandle_relation_count] == api_payload["quandle_relation_count"] - assert nif_payload[:colouring_count_3] == api_payload["colouring_count_3"] - assert nif_payload[:colouring_count_5] == api_payload["colouring_count_5"] + assert nif_payload[:determinant] == (if api_payload["determinant"] == :null, do: nil, else: api_payload["determinant"]) + assert nif_payload[:signature] == (if api_payload["signature"] == :null, do: nil, else: api_payload["signature"]) + assert nif_payload[:quandle_generator_count] == (if api_payload["quandle_generator_count"] == :null, do: nil, else: api_payload["quandle_generator_count"]) + assert nif_payload[:quandle_relation_count] == (if api_payload["quandle_relation_count"] == :null, do: nil, else: api_payload["quandle_relation_count"]) + assert nif_payload[:colouring_count_3] == (if api_payload["colouring_count_3"] == :null, do: nil, else: api_payload["colouring_count_3"]) + assert nif_payload[:colouring_count_5] == (if api_payload["colouring_count_5"] == :null, do: nil, else: api_payload["colouring_count_5"]) end test "semantic_equivalents matches API for configured knot" do diff --git a/beam/test/quandle_db_nif_test.exs b/beam/test/quandle_db_nif_test.exs index 4902a1f..19c0352 100644 --- a/beam/test/quandle_db_nif_test.exs +++ b/beam/test/quandle_db_nif_test.exs @@ -1,23 +1,40 @@ defmodule QuandleDBNifTest do - use ExUnit.Case + use ExUnit.Case, async: false - test "semantic_lookup returns scaffold payload" do - assert {:ok, payload} = QuandleDBNif.semantic_lookup("3_1") - assert payload[:name] == "3_1" - assert payload[:descriptor_version] == "stub-v1" - end + @base_url System.get_env("QDB_LIVE_TEST_BASE_URL") - test "semantic_equivalents returns scaffold buckets" do - assert {:ok, payload} = QuandleDBNif.semantic_equivalents("3_1") - assert payload[:name] == "3_1" - assert payload[:strong_candidates] == ["3_1"] - assert payload[:weak_candidates] == [] - assert payload[:combined_candidates] == ["3_1"] - assert payload[:count] == 1 - end + if @base_url == nil or @base_url == "" do + setup_all do + old_mode = System.get_env("QDB_NIF_MODE") + System.put_env("QDB_NIF_MODE", "stub") + on_exit(fn -> + if old_mode, do: System.put_env("QDB_NIF_MODE", old_mode), else: System.delete_env("QDB_NIF_MODE") + end) + :ok + end + + test "semantic_lookup returns scaffold payload" do + assert {:ok, payload} = QuandleDBNif.semantic_lookup("3_1") + assert payload[:name] == "3_1" + assert payload[:descriptor_version] == "stub-v1" + end + + test "semantic_equivalents returns scaffold buckets" do + assert {:ok, payload} = QuandleDBNif.semantic_equivalents("3_1") + assert payload[:name] == "3_1" + assert payload[:strong_candidates] == ["3_1"] + assert payload[:weak_candidates] == [] + assert payload[:combined_candidates] == ["3_1"] + assert payload[:count] == 1 + end - test "wrapper validates argument type" do - assert {:error, :invalid_argument} = QuandleDBNif.semantic_lookup(31) - assert {:error, :invalid_argument} = QuandleDBNif.semantic_equivalents(:bad) + test "wrapper validates argument type" do + assert {:error, :invalid_argument} = QuandleDBNif.semantic_lookup(31) + assert {:error, :invalid_argument} = QuandleDBNif.semantic_equivalents(:bad) + end + else + test "unit tests disabled when running full-stack integration tests" do + assert true + end end end diff --git a/server/Project.toml b/server/Project.toml index 6b1da10..0116192 100644 --- a/server/Project.toml +++ b/server/Project.toml @@ -10,9 +10,9 @@ SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9" Skein = "e8a1f3d0-7c42-4e9a-b5d1-3a7f8c2e1d0b" [sources] -AcceleratorGate = {path = "../../../AcceleratorGate.jl"} -KnotTheory = {path = "../../../KnotTheory.jl"} -Skein = {path = "../../../Skein.jl"} +AcceleratorGate = {path = "../../AcceleratorGate.jl"} +KnotTheory = {path = "../../KnotTheory.jl"} +Skein = {path = "../../Skein.jl"} [compat] AcceleratorGate = "0.1" diff --git a/server/seed_db.jl b/server/seed_db.jl new file mode 100644 index 0000000..b216b83 --- /dev/null +++ b/server/seed_db.jl @@ -0,0 +1,5 @@ +using Skein +db = SkeinDB("../data/knots.db") +n = Skein.import_knotinfo!(db) +println("Imported $n knots") +close(db) diff --git a/server/serve.jl b/server/serve.jl index 73f7ffa..8a8a37b 100644 --- a/server/serve.jl +++ b/server/serve.jl @@ -1008,17 +1008,19 @@ function router(db::SkeinDB, sdb::SemanticIndexDB, static_dir::String, m_equiv = match(r"^/api/semantic-equivalents/(.+)$", path) !isnothing(m_equiv) && - return handle_semantic_equivalents(db, sdb, m_equiv.captures[1]) + return handle_semantic_equivalents(db, sdb, String(m_equiv.captures[1])) m_sem = match(r"^/api/semantic/(.+)$", path) !isnothing(m_sem) && - return handle_semantic_detail(db, sdb, m_sem.captures[1]) + return handle_semantic_detail(db, sdb, String(m_sem.captures[1])) + + path == "/api/explain" && return handle_explain(db, sdb, params) path == "/api/explain" && return handle_explain(db, sdb, params) m_knot = match(r"^/api/knots/(.+)$", path) !isnothing(m_knot) && - return handle_knot_detail(db, sdb, m_knot.captures[1]) + return handle_knot_detail(db, sdb, String(m_knot.captures[1])) serve_static(static_dir, path) end @@ -1047,7 +1049,7 @@ function main() # ── Open databases ──────────────────────────────────────────────────────── db = SkeinDB(config.dbpath; readonly = true) - total = count_knots(db) + total = Skein.count_knots(db) semantic_path = abspath(config.semantic_index) mkpath(dirname(semantic_path)) diff --git a/tests/integration/full_stack_smoke.sh b/tests/integration/full_stack_smoke.sh new file mode 100755 index 0000000..e514fc8 --- /dev/null +++ b/tests/integration/full_stack_smoke.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 + +set -euo pipefail + +cd "$(dirname "$0")/../../" + +echo "=== QuandleDB Full-Stack Smoke Test ===" + +# Build the Julia server manifest if needed +echo "-> Instantiating Julia environment..." +julia --project=server -e 'using Pkg; Pkg.instantiate()' + +# Start the Julia HTTP server on a random high port +PORT=18081 +echo "-> Starting Julia server on port $PORT..." +julia --project=server server/serve.jl data/knots.db --port $PORT & +SERVER_PID=$! + +# Wait for server to be ready +echo "-> Waiting for server to become responsive..." +MAX_ATTEMPTS=30 +ATTEMPT=0 +READY=0 +while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + if curl -s "http://127.0.0.1:$PORT/api/knots?limit=1" > /dev/null; then + READY=1 + break + fi + sleep 1 + ATTEMPT=$((ATTEMPT + 1)) +done + +if [ $READY -eq 0 ]; then + echo "ERROR: Server failed to start or respond in time." + kill $SERVER_PID + exit 1 +fi + +echo "-> Server is ready." + +# Run the BEAM tests against the live server +echo "-> Running BEAM tests against live server..." +export QDB_LIVE_TEST_BASE_URL="http://127.0.0.1:$PORT" +export QDB_NIF_MODE="live" +export QDB_API_BASE_URL="http://127.0.0.1:$PORT" +cd beam +if mix test; then + echo "-> BEAM live integration tests passed!" + TEST_RESULT=0 +else + echo "ERROR: BEAM live integration tests failed." + TEST_RESULT=1 +fi +cd .. + +# Cleanup +echo "-> Terminating Julia server (PID $SERVER_PID)..." +kill $SERVER_PID || true + +echo "=== Smoke Test Complete (Exit Code: $TEST_RESULT) ===" +exit $TEST_RESULT