Files
viettranx f2c43c3849 test(facebook,store/pg): wave B coverage push
Phase 03 - facebook 23.1% -> 81.9% (+58.8%, target ≥60%)
  graph_api_test.go: NewGraphClient, VerifyToken, SubscribeApp, GetPost,
  GetComment, GetCommentThread, ReplyToComment, SendMessage, SendTypingOn,
  doRequest retry/backoff matrix (5xx retry, 401 non-retry, 429+Retry-After,
  context cancel during backoff, transport error + retry), 551/subcode 24h
  window non-retry, logRateLimit parse branches.

  handlers_test.go: New required-field validation (4 branches), Factory
  valid+malformed, WebhookHandler single-shot route, handleAPIError health
  state mapping, handleCommentEvent (feature gate, edit/remove drop, page
  routing, self-reply skip, dedup, enriched content path), handleMessagingEvent
  (feature gate, page routing, self skip, receipt drop, text+postback dispatch,
  dedup), Send Messenger+comment paths + missing-metadata error, sendFirstInbox
  one-shot dedup, runDedupCleaner stale eviction, Stop closes deps, webhookRouter
  register/unregister/route-once/ServeHTTP-no-instances, PostFetcher (defaults,
  empty-id, cache hit/miss/expired, error propagation, GetCommentThread
  empty+delegate).

  Minimal refactor: converted package-level graphAPIBase const to var so tests
  can point at httptest server. KISS, zero runtime behavior change.

Phase 04 - store/pg 1.3% -> 3.5% (+2.2%, unit-test-only)
  scan_rows_test.go: pure-logic transformation tests for agentShareRow,
  userInstanceRow, documentInfoRow, documentDetailRow, chunkInfoRow,
  scoredChunkRow, episodicSummaryRow, episodicScoredRow, sessionListRow,
  sessionPagedRow, sessionRichRow, entityRow, entityTemporalRow, relationRow,
  relationExportRow, traversalRow, dedupCandidateRow, mcpAccessRequestRow,
  cronRunLogRow, skillInfoRow+skillInfoRowWithFrontmatter, customSkillExportRow,
  parseDepsColumn, parseFrontmatterAuthor, marshalFrontmatter, buildSkillInfo,
  mergeEpisodicScores, hybridMerge.

  pure_logic_test.go: matchWildcard pattern matching, evalPermRows priority
  resolution (individual deny > individual allow > group deny > group allow),
  contactResolveCache get/set/expired/negative cache.

  Finding: 30% target not reachable with unit tests alone. Store/pg is
  fundamentally DB-bound (~6000 LOC of SQL code vs ~200 LOC pure logic).
  Reaching 30% requires running integration tests with TEST_DATABASE_URL and
  -coverpkg=./internal/store/pg/ — which the current CI ratchet does NOT
  do. Confirmed: integration tests yield 32% coverage on store/pg, but that
  requires a wired-up test database in CI. Deferred as infrastructure change
  outside Phase 04 scope.

Ratchet bumps (scripts/coverage_thresholds.json):
- internal/channels/facebook: 23.08 -> 81.80
- internal/store/pg: 1.28 -> 3.45

Race clean, dual build (default + -tags sqliteonly) clean.
2026-04-11 21:47:22 +07:00

178 lines
4.8 KiB
Go

package pg
import (
"testing"
"time"
)
// --- config_permissions.go: evalPermRows + matchWildcard + itoa ---
func TestMatchWildcard(t *testing.T) {
cases := []struct {
pattern string
value string
want bool
}{
{"*", "anything", true},
{"*", "", true},
{"group:*", "group:telegram:123", true},
{"group:*", "group:telegram", true},
{"group:*", "dm:telegram", false},
{"group:telegram:*", "group:telegram:123", true},
{"group:telegram:*", "group:telegram", true},
{"group:telegram:*", "group:slack:123", false},
{"exact", "exact", true},
{"exact", "other", false},
{"group:telegram:-100456", "group:telegram:-100456", true},
{"group:telegram:-100456", "group:telegram:-100457", false},
}
for _, tc := range cases {
t.Run(tc.pattern+" vs "+tc.value, func(t *testing.T) {
if got := matchWildcard(tc.pattern, tc.value); got != tc.want {
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tc.pattern, tc.value, got, tc.want)
}
})
}
}
func TestEvalPermRows_IndividualDenyWinsOverGroupAllow(t *testing.T) {
rows := []permRow{
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "allow"},
{Scope: "*", ConfigType: "*", UserID: "alice", Permission: "deny"},
}
if got := evalPermRows(rows, "any", "any", "alice"); got {
t.Error("individual deny should win over group allow")
}
}
func TestEvalPermRows_IndividualAllowWinsOverGroupDeny(t *testing.T) {
rows := []permRow{
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "deny"},
{Scope: "*", ConfigType: "*", UserID: "alice", Permission: "allow"},
}
if got := evalPermRows(rows, "any", "any", "alice"); !got {
t.Error("individual allow should win over group deny")
}
}
func TestEvalPermRows_GroupAllowWhenNoIndividual(t *testing.T) {
rows := []permRow{
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "allow"},
}
if got := evalPermRows(rows, "any", "any", "bob"); !got {
t.Error("group allow should apply when no individual rule")
}
}
func TestEvalPermRows_GroupDenyWhenNoIndividual(t *testing.T) {
rows := []permRow{
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "deny"},
}
if got := evalPermRows(rows, "any", "any", "bob"); got {
t.Error("group deny should apply when no individual rule")
}
}
func TestEvalPermRows_NoMatchDefaultsDeny(t *testing.T) {
rows := []permRow{
{Scope: "different", ConfigType: "*", UserID: "alice", Permission: "allow"},
}
// scope doesn't match → no rule applies → implicit deny.
if got := evalPermRows(rows, "other", "any", "alice"); got {
t.Error("no matching rule should default to deny")
}
}
func TestEvalPermRows_ScopeFiltering(t *testing.T) {
rows := []permRow{
{Scope: "group:*", ConfigType: "*", UserID: "*", Permission: "allow"},
}
if !evalPermRows(rows, "group:telegram:1", "skill", "u") {
t.Error("wildcarded scope should match")
}
if evalPermRows(rows, "dm:telegram", "skill", "u") {
t.Error("wildcarded scope should NOT match different prefix")
}
}
func TestEvalPermRows_ConfigTypeFiltering(t *testing.T) {
rows := []permRow{
{Scope: "*", ConfigType: "skill", UserID: "u", Permission: "allow"},
}
if !evalPermRows(rows, "any", "skill", "u") {
t.Error("exact config_type match should allow")
}
if evalPermRows(rows, "any", "other", "u") {
t.Error("different config_type should not allow")
}
}
func TestItoa(t *testing.T) {
cases := []struct {
n int
want string
}{
{0, "0"},
{1, "1"},
{42, "42"},
{-5, "-5"},
{1000, "1000"},
}
for _, tc := range cases {
if got := itoa(tc.n); got != tc.want {
t.Errorf("itoa(%d) = %q, want %q", tc.n, got, tc.want)
}
}
}
// --- contact_resolve.go: contactResolveCache ---
func TestContactResolveCache_GetSet(t *testing.T) {
c := newContactResolveCache()
// Miss.
if v, ok := c.get("k1"); ok {
t.Errorf("expected miss, got %q", v)
}
// Set and hit.
c.set("k1", "tenant-user-1")
if v, ok := c.get("k1"); !ok || v != "tenant-user-1" {
t.Errorf("get after set: got %q ok=%v", v, ok)
}
// Set another key without interfering.
c.set("k2", "tenant-user-2")
if v, ok := c.get("k2"); !ok || v != "tenant-user-2" {
t.Errorf("k2: got %q ok=%v", v, ok)
}
if v, ok := c.get("k1"); !ok || v != "tenant-user-1" {
t.Errorf("k1 after k2 set: got %q ok=%v", v, ok)
}
}
func TestContactResolveCache_NegativeResult(t *testing.T) {
c := newContactResolveCache()
// Negative results are cached as empty string with ok=true.
c.set("k1", "")
v, ok := c.get("k1")
if !ok {
t.Error("negative cache entry should be present")
}
if v != "" {
t.Errorf("negative v = %q, want empty", v)
}
}
func TestContactResolveCache_ExpiredEntry(t *testing.T) {
c := newContactResolveCache()
// Manually insert a stale entry.
c.items["k1"] = contactResolveEntry{
tenantUserID: "old-user",
fetched: time.Now().Add(-2 * contactResolveCacheTTL),
}
if _, ok := c.get("k1"); ok {
t.Error("expired entry should not be returned as hit")
}
}