# tests/testthat/test-gaia.R # get_gaia_data() is tested offline: gaia_post(), the only function that # touches the network, is replaced with local_mocked_bindings(). The fake # responses are minimal httr "response" objects, so httr::status_code() and # httr::content() run for real on them. gaia_response <- function(text, status = 200L, type = "application/json") { structure( list( url = "https://gea.esac.esa.int/tap-server/tap/sync", status_code = status, headers = structure(list("content-type" = type), class = c("insensitive", "list")), content = charToRaw(text), date = Sys.time() ), class = "response" ) } # Answer of the live archive (October 2026) to # SELECT source_id, ra, parallax FROM external.gaiaedr3_gcns_main_1 # WHERE parallax > 540 # (Barnard's star and Proxima Centauri), with the metadata trimmed and a # made-up third row whose parallax is null, to check NA handling. gaia_json <- paste0( '{"metadata": [', '{"name": "source_id", "datatype": "long", "unit": null},', '{"name": "ra", "datatype": "double", "unit": "deg"},', '{"name": "parallax", "datatype": "float", "unit": "mas"}],', '"data": [', "[4472832130942575872, 269.44850252543836, 546.97595],", "[5853498713190525696, 217.39232147200883, 768.0665],", "[1, 0.5, null]]}" ) # Error document the live archive sends (HTTP 400) for an unknown column; # note the escaped "<" in the echoed query. gaia_votable_error <- paste0( '\n', '\n', '\n', "Cannot parse query 'SELECT foo FROM external.gaiaedr3_gcns_main_1 ", "WHERE parallax < 50' for job 'f381dbc6-O': 1 unresolved ", "identifiers: foo [l.1 c.8 - l.1 c.11] !\n", ' - Unknown column "foo" !\n', "\n", '\n', '\n', "\n", "\n" ) # ---- fake response --------------------------------------------------------- test_that("the fake response is a valid httr response", { resp <- gaia_response('{"a": 1}', status = 503L) expect_identical(httr::status_code(resp), 503L) expect_identical(httr::content(resp, "text", encoding = "UTF-8"), '{"a": 1}') }) # ---- success --------------------------------------------------------------- test_that("get_gaia_data sends the ADQL query and parses the result", { calls <- list() local_mocked_bindings(gaia_post = function(url, body, timeout) { calls[[length(calls) + 1L]] <<- list(url = url, body = body, timeout = timeout) gaia_response(gaia_json) }) res <- expect_silent( get_gaia_data("source_id, ra, parallax", "parallax > 540") ) # one request, carrying the vars, the GCNS table and the condition expect_length(calls, 1L) expect_identical(calls[[1]]$url, "https://gea.esac.esa.int/tap-server/tap/sync") expect_identical( calls[[1]]$body, list( REQUEST = "doQuery", LANG = "ADQL", FORMAT = "json", PHASE = "RUN", QUERY = paste( "SELECT source_id, ra, parallax", "FROM external.gaiaedr3_gcns_main_1", "WHERE parallax > 540" ) ) ) expect_identical(calls[[1]]$timeout, 60) # values are the ones in gaia_json; all columns are double expect_s3_class(res, "data.frame") expect_identical(dim(res), c(3L, 3L)) expect_named(res, c("source_id", "ra", "parallax")) expect_true(all(vapply(res, is.double, logical(1)))) expect_equal(res$source_id, c(4472832130942575872, 5853498713190525696, 1), tolerance = 1e-15) expect_equal(res$ra, c(269.44850252543836, 217.39232147200883, 0.5), tolerance = 1e-15) expect_equal(res$parallax[1:2], c(546.97595, 768.0665), tolerance = 1e-15) expect_identical(is.na(res$parallax), c(FALSE, FALSE, TRUE)) }) test_that("get_gaia_data squishes column names and passes 'timeout' on", { calls <- list() local_mocked_bindings(gaia_post = function(url, body, timeout) { calls[[length(calls) + 1L]] <<- list(body = body, timeout = timeout) gaia_response(gaia_json) }) vars <- " source_id ,ra, parallax " res <- get_gaia_data(vars, "parallax > 540", timeout = 5) expect_named(res, c("source_id", "ra", "parallax")) expect_identical(calls[[1]]$timeout, 5) expect_true(grepl(vars, calls[[1]]$body$QUERY, fixed = TRUE)) }) # ---- request errors -------------------------------------------------------- test_that("get_gaia_data returns NULL with a message on a connection error", { # real curl failures carry curl_error_* classes; this one must not be # reported as a timeout local_mocked_bindings(gaia_post = function(url, body, timeout) { stop(errorCondition( paste0("Could not resolve hostname [gea.esac.esa.int]:\n", "Could not resolve host: gea.esac.esa.int"), class = c("curl_error_couldnt_resolve_host", "curl_error") )) }) expect_no_warning(expect_message( res <- withVisible(get_gaia_data("ra", "parallax > 40")), paste("Gaia Archive request failed, returning NULL:", "Could not resolve hostname [gea.esac.esa.int]:", "Could not resolve host: gea.esac.esa.int"), fixed = TRUE )) expect_null(res$value) expect_false(res$visible) }) test_that("get_gaia_data reports a timeout without naming which limit fired", { local_mocked_bindings(gaia_post = function(url, body, timeout) { stop(errorCondition( paste0("Timeout was reached [gea.esac.esa.int]:\nOperation timed out ", "after 5001 milliseconds with 0 bytes received"), class = c("curl_error_operation_timedout", "curl_error") )) }) expect_no_warning(expect_message( res <- withVisible(get_gaia_data("ra", "parallax > 40", timeout = 5)), paste("Gaia Archive request timed out, returning NULL:", "Timeout was reached [gea.esac.esa.int]: Operation timed out", "after 5001 milliseconds with 0 bytes received"), fixed = TRUE )) expect_null(res$value) expect_false(res$visible) }) test_that("gaia_post hands 'timeout' to httr::timeout() before connecting", { # httr::timeout() rejects values below 1 ms while building the request, so # this fails before any connection is attempted expect_error(gaia_post("http://127.0.0.1:1/", list(a = "1"), timeout = 1e-4), "Timeout cannot be less than 1 ms", fixed = TRUE) }) # ---- HTTP errors ----------------------------------------------------------- test_that("get_gaia_data returns NULL with the status on HTTP 500", { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response("Internal Server Error", status = 500L, type = "text/html") }) expect_no_warning(expect_message( res <- withVisible(get_gaia_data("ra", "parallax > 40")), "Gaia Archive returned HTTP status 500, returning NULL.", fixed = TRUE )) expect_null(res$value) expect_false(res$visible) }) test_that("get_gaia_data passes on the archive's error text (HTTP 400)", { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(gaia_votable_error, status = 400L, type = "application/xml;charset=UTF-8") }) expect_message( res <- get_gaia_data("foo", "parallax < 50"), paste("Gaia Archive returned HTTP status 400, returning NULL:", "Cannot parse query 'SELECT foo FROM", "external.gaiaedr3_gcns_main_1 WHERE parallax < 50' for job", "'f381dbc6-O': 1 unresolved identifiers: foo [l.1 c.8 - l.1 c.11]", "! - Unknown column \"foo\" !"), fixed = TRUE ) expect_null(res) }) # ---- malformed responses --------------------------------------------------- test_that("get_gaia_data returns NULL with a message on malformed JSON", { bodies <- c( html = "Service Unavailable", truncated = '{"metadata": [{"name": "ra"', empty = "" ) reasons <- c( html = "lexical error: invalid char in json text.", truncated = "parse error: premature EOF", empty = "parse error: premature EOF" ) for (case in names(bodies)) { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(bodies[[case]]) }) expect_no_warning(expect_message( res <- withVisible(get_gaia_data("ra", "parallax > 40")), paste("Gaia Archive response is not valid JSON, returning NULL:", reasons[[case]]), fixed = TRUE )) expect_null(res$value) expect_false(res$visible) } }) test_that("get_gaia_data shows a TAP error sent with status 200", { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(gaia_votable_error, type = "application/xml") }) expect_message( res <- get_gaia_data("foo", "parallax < 50"), paste("Gaia Archive response is not valid JSON, returning NULL:", "Cannot parse query"), fixed = TRUE ) expect_null(res) }) test_that("get_gaia_data does not read a file named by a non-JSON body", { # jsonlite::fromJSON() alone would read this file and return its data path <- tempfile(fileext = ".json") writeLines('{"data": [[1]]}', path) local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(path) }) expect_message( res <- get_gaia_data("ra", "parallax > 40"), "Gaia Archive response is not valid JSON, returning NULL:", fixed = TRUE ) expect_null(res) unlink(path) }) test_that("get_gaia_data returns NULL when the JSON has no 'data' element", { for (json in c('{"error": "service unavailable"}', "[1, 2]")) { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(json) }) expect_message( res <- withVisible(get_gaia_data("ra", "parallax > 40")), "Gaia Archive response has no 'data' element, returning NULL.", fixed = TRUE ) expect_null(res$value) expect_false(res$visible) } }) # ---- empty result ---------------------------------------------------------- test_that("get_gaia_data returns a zero-row data frame when nothing matches", { # same layout as the live archive's answer to a query with no matches local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(paste0( '{"metadata": [{"name": "source_id", "datatype": "long"},', '{"name": "ra", "datatype": "double"},', '{"name": "parallax", "datatype": "float"}],\n', '"data":\n[\n\n]\n}' )) }) expect_no_warning(expect_message( res <- withVisible( get_gaia_data("source_id, ra, parallax", "parallax > 100000") ), "Gaia Archive query returned no rows.", fixed = TRUE )) expect_true(res$visible) df <- res$value expect_s3_class(df, "data.frame") expect_identical(dim(df), c(0L, 3L)) expect_named(df, c("source_id", "ra", "parallax")) expect_true(all(vapply(df, is.double, logical(1)))) }) # ---- argument validation --------------------------------------------------- test_that("get_gaia_data rejects invalid arguments before any request", { called <- FALSE local_mocked_bindings(gaia_post = function(url, body, timeout) { called <<- TRUE gaia_response(gaia_json) }) bad_strings <- list(NULL, NA, NA_character_, "", " ", character(0), c("ra", "dec"), 1, list("ra"), factor("ra")) for (bad in bad_strings) { expect_error(get_gaia_data(bad, "parallax > 40"), "'vars' must be a single non-empty character string.", fixed = TRUE) expect_error(get_gaia_data("ra", bad), "'condition' must be a single non-empty character string.", fixed = TRUE) } bad_timeouts <- list(NULL, NA, NA_real_, NaN, 0, -1, 1e-4, Inf, "60", c(10, 20), numeric(0), TRUE) for (bad in bad_timeouts) { expect_error(get_gaia_data("ra", "parallax > 40", timeout = bad), "'timeout' must be a single number of seconds, at least 0.001.", fixed = TRUE) } expect_error(get_gaia_data(condition = "parallax > 40"), "vars") expect_error(get_gaia_data("ra"), "condition") expect_false(called) }) test_that("get_gaia_data accepts the smallest allowed timeout", { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response(gaia_json) }) res <- get_gaia_data("source_id, ra, parallax", "parallax > 540", timeout = 0.001) expect_identical(dim(res), c(3L, 3L)) }) # ---- unexpected response layouts ------------------------------------------- test_that("get_gaia_data returns NULL when 'data' is not a table", { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response('{"metadata": [], "data": {"ra": 1}}') }) expect_no_warning(expect_message( res <- withVisible(get_gaia_data("ra", "parallax > 40")), "Gaia Archive response has an unexpected 'data' layout, returning NULL.", fixed = TRUE )) expect_null(res$value) expect_false(res$visible) }) test_that("get_gaia_data returns NULL when fewer columns come back than names", { local_mocked_bindings(gaia_post = function(url, body, timeout) { gaia_response('{"metadata": [], "data": [[1.5], [2.5]]}') }) expect_no_warning(expect_message( res <- withVisible(get_gaia_data("ra, dec", "parallax > 40")), "Gaia Archive returned fewer columns (1) than 'vars'", fixed = TRUE )) expect_null(res$value) }) # ---- gaia_tap_error() ------------------------------------------------------ test_that("gaia_tap_error ignores self-closing and unrelated INFO elements", { expect_null(gaia_tap_error( 'unrelated' )) expect_null(gaia_tap_error("plain text without any INFO element")) }) test_that("gaia_tap_error unescapes entities, with & last", { expect_identical( gaia_tap_error('a &lt; b & c'), "a < b & c" ) })