From 1b6d13186d8f12f33c91abd66471f9aaf1acefcb Mon Sep 17 00:00:00 2001 From: Shuu Date: Fri, 18 Sep 2026 16:29:51 +0900 Subject: [PATCH] test: pin DB-free coverage for structural test gaps --- tests/all_tests.nim | 10 ++-- tests/test_bytes.nim | 106 +++++++++++++++++++++++++++++++++++ tests/test_cache.nim | 111 +++++++++++++++++++++++++++++++++++++ tests/test_conn_types.nim | 79 ++++++++++++++++++++++++++ tests/test_dsn.nim | 32 +++++++++++ tests/test_errors.nim | 101 +++++++++++++++++++++++++++++++++ tests/test_pool.nim | 41 ++++++++++---- tests/test_protocol.nim | 42 ++++++++++++++ tests/test_replication.nim | 24 ++++++++ tests/test_types.nim | 63 +++++++++++++++++++++ 10 files changed, 592 insertions(+), 17 deletions(-) create mode 100644 tests/test_bytes.nim create mode 100644 tests/test_cache.nim create mode 100644 tests/test_conn_types.nim create mode 100644 tests/test_errors.nim diff --git a/tests/all_tests.nim b/tests/all_tests.nim index c14bd74..6b7763c 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -1,11 +1,11 @@ {.push warning[UnusedImport]: off.} import test_abandonment_e2e, test_advisory_lock, test_aggregate, test_async_backend, - test_auth, test_cancel_e2e, test_copy_race, test_dsn, test_e2e_arrays, - test_e2e_connection, test_e2e_convenience, test_e2e_copy, test_e2e_cursor, - test_e2e_listen, test_e2e_misc, test_e2e_pool, test_e2e_query, test_e2e_transaction, - test_e2e_types, test_fill_recvbuf, test_keepalive, test_largeobject, - test_largeobject_parse, test_listen_reconnect, test_network_failure, + test_auth, test_bytes, test_cache, test_cancel_e2e, test_conn_types, test_copy_race, + test_dsn, test_e2e_arrays, test_e2e_connection, test_e2e_convenience, test_e2e_copy, + test_e2e_cursor, test_e2e_listen, test_e2e_misc, test_e2e_pool, test_e2e_query, + test_e2e_transaction, test_e2e_types, test_errors, test_fill_recvbuf, test_keepalive, + test_largeobject, test_largeobject_parse, test_listen_reconnect, test_network_failure, test_physical_replication, test_pool, test_protocol, test_protocol_fuzz, test_replication, test_replication_keepalive, test_rowdata, test_saslprep, test_session_attrs, test_sql, test_ssl, test_tls_error_paths, test_tracing, diff --git a/tests/test_bytes.nim b/tests/test_bytes.nim new file mode 100644 index 0000000..d465d89 --- /dev/null +++ b/tests/test_bytes.nim @@ -0,0 +1,106 @@ +## Dedicated unit tests for ``pg_bytes`` — big-endian helpers and bounded copies. +## Keeps the leaf encoding primitives covered without relying on protocol suites. + +import std/unittest + +import ../async_postgres/[pg_bytes, pg_errors] + +suite "pg_bytes big-endian encode/decode": + test "encodes known big-endian byte order": + check toBE16(0x0102'i16) == [1'u8, 2'u8] + check toBE16(-1'i16) == [0xFF'u8, 0xFF'u8] + check toBE32(0x01020304'i32) == [1'u8, 2'u8, 3'u8, 4'u8] + check toBE32(-1'i32) == [0xFF'u8, 0xFF'u8, 0xFF'u8, 0xFF'u8] + check toBE64(0x1122334455667788'i64) == + [0x11'u8, 0x22'u8, 0x33'u8, 0x44'u8, 0x55'u8, 0x66'u8, 0x77'u8, 0x88'u8] + check fromBE16([1'u8, 2'u8]) == 0x0102'i16 + check fromBE32([1'u8, 2'u8, 3'u8, 4'u8]) == 0x01020304'i32 + check fromBE64( + [0x11'u8, 0x22'u8, 0x33'u8, 0x44'u8, 0x55'u8, 0x66'u8, 0x77'u8, 0x88'u8] + ) == 0x1122334455667788'i64 + + test "toBE16/fromBE16 roundtrip including negatives": + for v in [0'i16, 1'i16, -1'i16, high(int16), low(int16)]: + let enc = toBE16(v) + check fromBE16(enc) == v + + test "toBE32/fromBE32 roundtrip including negatives": + for v in [0'i32, 1'i32, -1'i32, high(int32), low(int32)]: + let enc = toBE32(v) + check fromBE32(enc) == v + + test "toBE64/fromBE64 roundtrip including negatives": + for v in [0'i64, 1'i64, -1'i64, high(int64), low(int64)]: + let enc = toBE64(v) + check fromBE64(enc) == v + + test "writeBE* writes big-endian bytes at an offset": + var buf = newSeq[byte](16) + writeBE16(buf, 2, 0x0102'i16) + writeBE32(buf, 4, 0x01020304'i32) + writeBE64(buf, 8, 0x1122334455667788'i64) + check buf[2 ..< 4] == @[1'u8, 2'u8] + check buf[4 ..< 8] == @[1'u8, 2'u8, 3'u8, 4'u8] + check buf[8 ..< 16] == + @[0x11'u8, 0x22'u8, 0x33'u8, 0x44'u8, 0x55'u8, 0x66'u8, 0x77'u8, 0x88'u8] + + test "writeBE* matches toBE* at an offset": + var buf = newSeq[byte](16) + writeBE16(buf, 2, -42'i16) + writeBE32(buf, 4, 0x01020304'i32) + writeBE64(buf, 8, 0x1122334455667788'i64) + check fromBE16(buf, 2) == -42'i16 + check fromBE32(buf, 4) == 0x01020304'i32 + check fromBE64(buf, 8) == 0x1122334455667788'i64 + + test "decodeFloat32BE / decodeFloat64BE decode known byte order": + check decodeFloat32BE([0x3F'u8, 0xC0'u8, 0x00'u8, 0x00'u8]) == 1.5'f32 + check decodeFloat64BE( + [0xC0'u8, 0x02'u8, 0x00'u8, 0x00'u8, 0x00'u8, 0x00'u8, 0x00'u8, 0x00'u8] + ) == -2.25'f64 + + test "decodeFloat32BE / decodeFloat64BE match IEEE bit patterns": + let f32 = 1.5'f32 + let f64 = -2.25'f64 + check decodeFloat32BE(@(toBE32(cast[int32](cast[uint32](f32))))) == f32 + check decodeFloat64BE(@(toBE64(cast[int64](cast[uint64](f64))))) == f64 + +suite "pg_bytes bounded copies": + test "writeBytesAt copies and rejects out-of-range slices": + var dst = newSeq[byte](4) + let src = @[1'u8, 2] + writeBytesAt(dst, 1, src) + check dst == @[0'u8, 1, 2, 0] + let empty: seq[byte] = @[] + writeBytesAt(dst, 0, empty) # empty is a no-op + check dst == @[0'u8, 1, 2, 0] + let tooLong = @[9'u8, 9] + expect PgProtocolError: + writeBytesAt(dst, 3, tooLong) + let one = @[1'u8] + expect PgProtocolError: + writeBytesAt(dst, -1, one) + + test "appendBytes grows the destination": + var buf: seq[byte] + let empty: seq[byte] = @[] + appendBytes(buf, empty) + check buf.len == 0 + let a = @[1'u8, 2, 3] + let b = @[4'u8] + appendBytes(buf, a) + appendBytes(buf, b) + check buf == @[1'u8, 2, 3, 4] + + test "readString / readBytes copy and reject bad ranges": + let src = @[byte('a'), byte('b'), byte('c'), byte('d')] + check readString(src, 1, 2) == "bc" + check readBytes(src, 0, 4) == src + check readString(src, 0, 0) == "" + check readBytes(src, 2, 0) == newSeq[byte]() + expect PgProtocolError: + discard readString(src, 2, 3) + expect PgProtocolError: + discard readBytes(src, -1, 1) + expect PgProtocolError: + discard readString(src, 0, -1) diff --git a/tests/test_cache.nim b/tests/test_cache.nim new file mode 100644 index 0000000..381cc1b --- /dev/null +++ b/tests/test_cache.nim @@ -0,0 +1,111 @@ +## Dedicated unit tests for ``pg_connection/cache`` — LRU order, capacity-0 +## disable, defensive eviction into ``pendingStmtCloses``, and Close staging. + +import std/[unittest, tables, strutils, importutils] + +import ../async_postgres/[async_backend, pg_protocol, pg_types] +import ../async_postgres/pg_connection/types {.all.} +import ../async_postgres/pg_connection/cache {.all.} + +privateAccess(PgConnection) + +proc mockConn(capacity: int = 2): PgConnection = + PgConnection( + recvBuf: @[], + state: csReady, + txStatus: tsIdle, + serverParams: initTable[string, string](), + createdAt: Moment.now(), + stmtCacheCapacity: capacity, + ) + +proc cached(name: string, fields: seq[FieldDescription] = @[]): CachedStmt = + CachedStmt(name: name, fields: fields) + +suite "stmt cache LRU": + test "capacity 0 disables lookup and add": + let conn = mockConn(0) + conn.addStmtCache("SELECT 1", cached("_sc_1")) + check conn.stmtCache.len == 0 + check conn.lookupStmtCache("SELECT 1").isNil + check not conn.stmtCachingEnabled + + test "hit moves entry to MRU and miss returns nil": + let conn = mockConn(2) + conn.addStmtCache("a", cached("_sc_a")) + conn.addStmtCache("b", cached("_sc_b")) + check conn.lookupStmtCache("a").name == "_sc_a" + # After touching "a", "b" is LRU and is the next eviction victim. + conn.addStmtCache("c", cached("_sc_c")) + check conn.lookupStmtCache("b").isNil + check conn.lookupStmtCache("a").name == "_sc_a" + check conn.lookupStmtCache("c").name == "_sc_c" + check conn.pendingStmtCloses == @["_sc_b"] + + test "defensive add eviction queues Close names": + let conn = mockConn(1) + conn.addStmtCache("old", cached("_sc_old")) + conn.addStmtCache("new", cached("_sc_new")) + check conn.stmtCache.len == 1 + check conn.lookupStmtCache("new").name == "_sc_new" + check conn.pendingStmtCloses == @["_sc_old"] + + test "clearStmtCache drops LRU, pending, and staged Closes": + let conn = mockConn(2) + conn.addStmtCache("a", cached("_sc_a")) + conn.pendingStmtCloses = @["_sc_x"] + conn.stagedStmtCloses = @["_sc_y"] + conn.clearStmtCache() + check conn.stmtCache.len == 0 + check conn.lookupStmtCache("a").isNil + check conn.pendingStmtCloses.len == 0 + check conn.stagedStmtCloses.len == 0 + + test "removeStmtCache drops one entry without touching others": + let conn = mockConn(2) + conn.addStmtCache("a", cached("_sc_a")) + conn.addStmtCache("b", cached("_sc_b")) + conn.removeStmtCache("a") + check conn.lookupStmtCache("a").isNil + check conn.lookupStmtCache("b").name == "_sc_b" + + test "addStmtCache fills resultFormats from field OIDs": + let conn = mockConn(2) + let fields = @[ + FieldDescription(name: "i", typeOid: OidInt4, formatCode: 0), + FieldDescription(name: "t", typeOid: OidText, formatCode: 0), + ] + conn.addStmtCache("SELECT 1", cached("_sc_1", fields)) + let entry = conn.lookupStmtCache("SELECT 1") + check entry.resultFormats == @[1'i16, 1'i16] + check entry.colOids == @[OidInt4, OidText] + check entry.colFmts == @[1'i16, 1'i16] + + test "nextStmtName is unique and prefixed": + let conn = mockConn() + let a = conn.nextStmtName() + let b = conn.nextStmtName() + check a.startsWith(stmtNamePrefix) + check b.startsWith(stmtNamePrefix) + check a != b + + test "stagePendingStmtCloses empties the queue into staged + buffer": + let conn = mockConn(1) + conn.pendingStmtCloses = @["_sc_1", "_sc_2"] + var buf: seq[byte] + conn.stagePendingStmtCloses(buf) + check conn.pendingStmtCloses.len == 0 + check conn.stagedStmtCloses == @["_sc_1", "_sc_2"] + # Two Close('S') frames: type + int32 len + 'S' + cstring + NUL each. + check buf.len > 0 + check buf[0] == byte('C') + + test "beginSendBuf clears then stages owed Closes": + let conn = mockConn(1) + conn.sendBuf = @[1'u8, 2, 3] + conn.pendingStmtCloses = @["_sc_owed"] + conn.beginSendBuf() + check conn.sendBuf.len > 0 + check conn.sendBuf[0] == byte('C') + check conn.stagedStmtCloses == @["_sc_owed"] + check conn.pendingStmtCloses.len == 0 diff --git a/tests/test_conn_types.nim b/tests/test_conn_types.nim new file mode 100644 index 0000000..0febc32 --- /dev/null +++ b/tests/test_conn_types.nim @@ -0,0 +1,79 @@ +## Dedicated unit tests for pure helpers in ``pg_connection/types``. +## Covers default resolution, portal naming, dial/display host, and raw +## replication LSN bookkeeping — all without a live server or mock transport. + +import std/[unittest, tables, importutils] + +import ../async_postgres/[async_backend, pg_protocol, pg_auth] +import ../async_postgres/pg_connection/types {.all.} + +privateAccess(PgConnection) + +proc bareConn(config = ConnConfig()): PgConnection = + PgConnection( + recvBuf: @[], + state: csReady, + txStatus: tsIdle, + serverParams: initTable[string, string](), + createdAt: Moment.now(), + config: config, + ) + +suite "conn-types effective defaults": + test "effectiveMaxMessageSize resolves 0 to the protocol default": + let conn = bareConn() + check conn.config.maxMessageSize == 0 + check conn.effectiveMaxMessageSize == DefaultMaxBackendMessageLen + conn.config.maxMessageSize = 4096 + check conn.effectiveMaxMessageSize == 4096 + + test "effectiveMaxScramIterations resolves 0 to the auth default": + var cfg = ConnConfig() + check cfg.maxScramIterations == 0 + check effectiveMaxScramIterations(cfg) == DefaultMaxScramIterations + cfg.maxScramIterations = 1000 + check effectiveMaxScramIterations(cfg) == 1000 + +suite "conn-types portal and host helpers": + test "nextPortalName is monotonic under a shared prefix": + let conn = bareConn() + let a = conn.nextPortalName("p") + let b = conn.nextPortalName("p") + check a == "p1" + check b == "p2" + + test "dialAddr prefers hostaddr; displayHost prefers host": + let both = HostEntry(host: "db.example", hostaddr: "10.0.0.1", port: 5432) + check dialAddr(both) == "10.0.0.1" + check displayHost(both) == "db.example" + let onlyHost = HostEntry(host: "db.example", port: 5432) + check dialAddr(onlyHost) == "db.example" + check displayHost(onlyHost) == "db.example" + let onlyAddr = HostEntry(hostaddr: "10.0.0.1", port: 5432) + check dialAddr(onlyAddr) == "10.0.0.1" + check displayHost(onlyAddr) == "10.0.0.1" + +suite "conn-types replication LSN helpers": + test "init / update / confirm clamp and advance monotonically": + let conn = bareConn() + conn.initReplLsnTracking(100) + check conn.replConfirmedFlushLsn == 100 + check conn.replMaxReceivedLsn == 100 + + check not conn.updateReplMaxReceivedLsn(50) + check conn.replMaxReceivedLsn == 100 + check conn.updateReplMaxReceivedLsn(150) + check conn.replMaxReceivedLsn == 150 + + # Above max-received clamps to max and advances flush. + check conn.confirmReplFlushed(200) + check conn.replConfirmedFlushLsn == 150 + # Below confirmed is a no-op; between confirmed and max advances. + check not conn.confirmReplFlushed(110) + check conn.replConfirmedFlushLsn == 150 + conn.initReplLsnTracking(100) + discard conn.updateReplMaxReceivedLsn(150) + check conn.confirmReplFlushed(120) + check conn.replConfirmedFlushLsn == 120 + check not conn.confirmReplFlushed(110) + check conn.replConfirmedFlushLsn == 120 diff --git a/tests/test_dsn.nim b/tests/test_dsn.nim index af64743..d17987d 100644 --- a/tests/test_dsn.nim +++ b/tests/test_dsn.nim @@ -794,6 +794,38 @@ suite "parseDsn": check cfg.sslCert == dummyPem check cfg.sslKey == dummyPem + test "error: empty sslcert PEM file rejected": + let certPath = writePemFile("") + let keyPath = writeKeyFile(dummyPem) + defer: + removeFile(certPath) + removeFile(keyPath) + var raised = false + try: + discard parseDsn( + "postgresql://host/db?sslmode=require&sslcert=" & certPath & "&sslkey=" & keyPath + ) + except PgConfigError as e: + raised = true + check "file is empty" in e.msg + check raised + + test "error: empty sslkey PEM file rejected": + let certPath = writePemFile(dummyPem) + let keyPath = writeKeyFile("") + defer: + removeFile(certPath) + removeFile(keyPath) + var raised = false + try: + discard parseDsn( + "postgresql://host/db?sslmode=require&sslcert=" & certPath & "&sslkey=" & keyPath + ) + except PgConfigError as e: + raised = true + check "file is empty" in e.msg + check raised + test "error: sslcert with sslmode=disable rejected": let certPath = writePemFile(dummyPem) let keyPath = writeKeyFile(dummyPem) diff --git a/tests/test_errors.nim b/tests/test_errors.nim new file mode 100644 index 0000000..6dd5b7d --- /dev/null +++ b/tests/test_errors.nim @@ -0,0 +1,101 @@ +## Dedicated unit tests for ``pg_errors`` — hierarchy, SQLSTATE helpers, and +## overflow-safe position parsing. Pins recovery-oriented exception contracts +## without needing a live server. + +import std/unittest + +import ../async_postgres/pg_errors + +suite "pg_errors hierarchy": + test "protocol and timeout errors are connection errors": + check (ref PgProtocolError)() of PgConnectionError + check (ref PgTimeoutError)() of PgConnectionError + check (ref PgListenError)() of PgConnectionError + + test "state and config errors are siblings, not connection errors": + check (ref PgStateError)() of PgError + check (ref PgConfigError)() of PgError + check not ((ref PgStateError)() of PgConnectionError) + check not ((ref PgConfigError)() of PgConnectionError) + check (ref PgListenStoppedError)() of PgStateError + check not ((ref PgListenStoppedError)() of PgConnectionError) + + test "type / message-size / query / pool sit under PgError": + check (ref PgTypeError)() of PgError + check (ref PgMessageTooLargeError)() of PgTypeError + check (ref PgQueryError)() of PgError + check (ref PgPoolError)() of PgError + check (ref PgNoRowsError)() of PgError + check (ref PgNullError)() of PgError + check (ref PgNotifyOverflowError)() of PgError + + test "newPoolError records kind and optional parent": + let parent = (ref ValueError)(msg: "boom") + let err = newPoolError(pekQueueFull, "full", parent) + check err.kind == pekQueueFull + check err.msg == "full" + check err.parent == parent + +suite "pg_errors field accessors": + test "getErrorField returns first matching code": + let fields = @[ + ErrorField(code: 'M', value: "msg"), + ErrorField(code: 'C', value: "23505"), + ErrorField(code: 'M', value: "later"), + ] + check getErrorField(fields, 'M') == "msg" + check getErrorField(fields, 'C') == "23505" + check getErrorField(fields, 'H') == "" + + test "PgQueryError named accessors and SQLSTATE predicates": + let err = (ref PgQueryError)( + msg: "dup", + sqlState: SqlStateUniqueViolation, + severity: "ERROR", + detail: "d", + hint: "h", + fields: @[ + ErrorField(code: 's', value: "public"), + ErrorField(code: 't', value: "users"), + ErrorField(code: 'c', value: "email"), + ErrorField(code: 'd', value: "text"), + ErrorField(code: 'n', value: "users_email_key"), + ErrorField(code: 'W', value: "PL/pgSQL"), + ErrorField(code: 'q', value: "SELECT 1"), + ErrorField(code: 'P', value: "12"), + ErrorField(code: 'p', value: "3"), + ], + ) + check err.schemaName == "public" + check err.tableName == "users" + check err.columnName == "email" + check err.dataTypeName == "text" + check err.constraintName == "users_email_key" + check err.where == "PL/pgSQL" + check err.internalQuery == "SELECT 1" + check err.position == 12 + check err.internalPosition == 3 + check err.isUniqueViolation + check err.isIntegrityConstraintViolation + check not err.isSerializationFailure + + test "position parsing rejects non-digits and overflow without Defect": + let bad = (ref PgQueryError)(fields: @[ErrorField(code: 'P', value: "12x")]) + check bad.position == 0 + # Digits that would overflow int must clamp to "not present" (0), never + # raise an uncatchable OverflowDefect for a caller that only reads position. + var huge = "9" + for i in 0 ..< 80: + huge.add('9') + let overflow = (ref PgQueryError)(fields: @[ErrorField(code: 'P', value: huge)]) + check overflow.position == 0 + + test "integrity class predicate covers known 23xxx codes": + for state in [ + SqlStateNotNullViolation, SqlStateForeignKeyViolation, SqlStateUniqueViolation, + SqlStateCheckViolation, SqlStateExclusionViolation, + ]: + let err = (ref PgQueryError)(sqlState: state) + check err.isIntegrityConstraintViolation + check not (ref PgQueryError)(sqlState: SqlStateSyntaxError).isIntegrityConstraintViolation + check not (ref PgQueryError)(sqlState: "23").isIntegrityConstraintViolation diff --git a/tests/test_pool.nim b/tests/test_pool.nim index 3cc12e1..1ee2648 100644 --- a/tests/test_pool.nim +++ b/tests/test_pool.nim @@ -3442,8 +3442,14 @@ suite "Pool metrics": let pool = makePool() let conn = mockConn() pool.idle.addLast(conn.toPooled()) + let before = pool.metrics.acquireDuration discard waitFor pool.acquire() - check pool.metrics.acquireDuration >= ZeroDuration + # Idle handoff is near-instant, so this only pins monotonicity (no clock + # regression). It cannot tell a missing duration write (`== before`) from + # a real one; measurable accumulation is pinned by + # "waiter transfer tracks acquireDuration" below. + check pool.metrics.acquireDuration >= before + check pool.metrics.acquireCount == 1 test "acquire skipping broken connections increments closeCount": let pool = makePool() @@ -3530,28 +3536,39 @@ suite "Pool metrics": check pool.metrics.closeCount == 1 check pool.metrics.acquireCount == 1 - test "acquireDuration accumulates across multiple acquires": + test "acquireDuration is monotonic across multiple acquires": let pool = makePool() + var previous = ZeroDuration for i in 0 ..< 3: let conn = mockConn() pool.idle.addLast(conn.toPooled()) discard waitFor pool.acquire() + # Same limitation as above: idle handoffs add near-zero time, so this + # pins monotonicity only. Measurable accumulation is pinned by + # "waiter transfer tracks acquireDuration" below. + check pool.metrics.acquireDuration >= previous + previous = pool.metrics.acquireDuration pool.active.dec check pool.metrics.acquireCount == 3 - check pool.metrics.acquireDuration >= ZeroDuration test "waiter transfer tracks acquireDuration": - let pool = makePool(maxSize = 1) - pool.active = 1 + proc t() {.async.} = + let pool = makePool(maxSize = 1) + pool.active = 1 - let acquireFut = pool.acquire() - check not acquireFut.finished + let acquireFut = pool.acquire() + doAssert not acquireFut.finished - let conn = mockConn() - pool.release(conn) - discard waitFor acquireFut - check pool.metrics.acquireCount == 1 - check pool.metrics.acquireDuration >= ZeroDuration + # Sleep so the waiter path must accumulate a measurable wait time; + # `>= ZeroDuration` alone is a tautology for Duration. + await sleepAsync(milliseconds(20)) + let conn = mockConn() + pool.release(conn) + discard await acquireFut + doAssert pool.metrics.acquireCount == 1 + doAssert pool.metrics.acquireDuration >= milliseconds(1) + + waitFor t() test "acquire timeout does not increment createCount": proc t() {.async.} = diff --git a/tests/test_protocol.nim b/tests/test_protocol.nim index ddfde1a..7ce335d 100644 --- a/tests/test_protocol.nim +++ b/tests/test_protocol.nim @@ -2802,3 +2802,45 @@ when defined(pgStateChecks): conn.stagedStmtCloses = @["_sc_1"] conn.checkReady() check conn.stagedStmtCloses == @["_sc_1"] + +suite "buildResultFormats": + test "marks binary-safe OIDs as format 1 and others as 0": + let fields = @[ + FieldDescription(name: "i", typeOid: OidInt4, formatCode: 0), + FieldDescription(name: "t", typeOid: OidText, formatCode: 0), + FieldDescription(name: "b", typeOid: OidBool, formatCode: 0), + FieldDescription(name: "u", typeOid: 999999'i32, formatCode: 0), + ] + let fmts = buildResultFormats(fields) + check fmts == @[1'i16, 1'i16, 1'i16, 0'i16] + check isBinarySafeOid(OidInt4) + check isBinarySafeOid(OidText) + check isBinarySafeOid(OidBool) + check not isBinarySafeOid(-1) + check not isBinarySafeOid(999999'i32) + + test "empty fields yields empty formats": + check buildResultFormats(@[]).len == 0 + +suite "patchMsgLenAtomic": + test "patches length and leaves a valid frontend frame": + var buf: seq[byte] + let start = buf.len + buf.add(byte('Q')) + buf.addInt32(0) # placeholder length + buf.add(@[byte('S'), byte('E'), byte('L'), 0'u8]) + buf.patchMsgLenAtomic(start) + check buf[0] == byte('Q') + check fromBE32(buf, 1) == int32(buf.len - 1) + + test "out-of-range msgStart truncates back to msgStart before raising": + var buf = @[1'u8, 2, 3, 4, 5] + expect PgProtocolError: + buf.patchMsgLenAtomic(3) # 3+4 >= 5 + check buf == @[1'u8, 2, 3] + + test "negative msgStart raises without truncating": + var buf = @[1'u8, 2, 3, 4, 5] + expect PgProtocolError: + buf.patchMsgLenAtomic(-1) + check buf == @[1'u8, 2, 3, 4, 5] diff --git a/tests/test_replication.nim b/tests/test_replication.nim index cc01b4a..bac981c 100644 --- a/tests/test_replication.nim +++ b/tests/test_replication.nim @@ -915,3 +915,27 @@ suite "decodeReadSlotRow": let qr = mkReadQr(["physical", "0/16B3740", "not-an-int"], 3) expect(PgTypeError): discard decodeReadSlotRow(qr, "my_phys") + +suite "parseReplicationMessage defense branches": + test "empty CopyData is rejected": + expect PgProtocolError: + discard parseReplicationMessage(@[]) + + test "truncated XLogData is rejected": + var payload: seq[byte] + payload.add(byte('w')) + payload.addInt64(1'i64) + # Fewer than the required 25 bytes (type + 3×int64). + expect PgProtocolError: + discard parseReplicationMessage(payload) + + test "truncated PrimaryKeepalive is rejected": + var payload: seq[byte] + payload.add(byte('k')) + payload.addInt64(1'i64) + expect PgProtocolError: + discard parseReplicationMessage(payload) + + test "unknown replication message type is rejected": + expect PgProtocolError: + discard parseReplicationMessage(@[byte('Z')]) diff --git a/tests/test_types.nim b/tests/test_types.nim index 5ef0256..0072033 100644 --- a/tests/test_types.nim +++ b/tests/test_types.nim @@ -11736,3 +11736,66 @@ suite "text parsers follow PostgreSQL's grammar, not Nim's": discard pgParseUIntField("9223372036854775808", "ctx") expect PgTypeError: discard pgParseUIntField("99999999999999999999", "ctx") + +suite "getBoxArray text format": + test "parses semicolon-delimited box literals": + # PostgreSQL's box[] text form uses ';' between elements (not ',') because + # each box already contains commas. Exercise the dedicated text path. + let row = + mkRow(@[some(toBytes("{(3,4),(1,2);(7,8),(5,6)}"))], @[mkField(OidBoxArray, 0)]) + let arr = row.getBoxArray(0) + check arr.len == 2 + check arr[0].high == PgPoint(x: 3.0, y: 4.0) + check arr[0].low == PgPoint(x: 1.0, y: 2.0) + check arr[1].high == PgPoint(x: 7.0, y: 8.0) + check arr[1].low == PgPoint(x: 5.0, y: 6.0) + + test "empty box array literal": + let row = mkRow(@[some(toBytes("{}"))], @[mkField(OidBoxArray, 0)]) + check row.getBoxArray(0).len == 0 + + test "rejects NULL elements in text form": + let row = mkRow(@[some(toBytes("{(1,2),(3,4);NULL}"))], @[mkField(OidBoxArray, 0)]) + expect PgTypeError: + discard row.getBoxArray(0) + +suite "row columnIndex by name": + test "resolves names from row field metadata": + let fields = @[ + FieldDescription( + name: "id", typeOid: OidInt4, typeSize: 4, typeMod: -1, formatCode: 0 + ), + FieldDescription( + name: "name", typeOid: OidText, typeSize: -1, typeMod: -1, formatCode: 0 + ), + ] + let row = mkRow(@[some(toBytes("1")), some(toBytes("alice"))], fields) + check row.columnIndex("id") == 0 + check row.columnIndex("name") == 1 + check row.getStr("name") == "alice" + + test "raises when field metadata is missing": + # Manual Row without FieldDescription metadata (converter path). + let row: Row = @[some(toBytes("x"))] + var raised = false + try: + discard row.columnIndex("x") + except PgTypeError as e: + raised = true + check "field metadata" in e.msg + check raised + + test "raises when the column name is absent": + let fields = @[ + FieldDescription( + name: "id", typeOid: OidInt4, typeSize: 4, typeMod: -1, formatCode: 0 + ) + ] + let row = mkRow(@[some(toBytes("1"))], fields) + var raised = false + try: + discard row.columnIndex("missing") + except PgTypeError as e: + raised = true + check "Column not found" in e.msg + check raised