AuthorBot Cursor commited on
Commit
a825fee
·
1 Parent(s): ccbebe0

Unify token tracking, smart links, and personality settings across admin and chat.

Browse files

Add centralized token budget accounting with bonus tokens, sync buy URLs to the links table, wire response_style into prompts, block suspended authors, and add a 25-test suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

app/admin/router.py CHANGED
@@ -698,13 +698,15 @@ async def get_embed_token(
698
  exp = exp.replace(tzinfo=None)
699
  days_remaining = max(0, (exp - now).days) if exp else 0
700
 
 
 
701
  return {
702
  "token": token,
703
  "grant_id": access.id,
704
  "plan": access.plan,
705
  "expires_at": access.expires_at.isoformat(),
706
  "days_remaining": days_remaining,
707
- "tokens_remaining": max(0, (access.token_budget + access.bonus_tokens) - access.tokens_used),
708
  }
709
 
710
 
@@ -729,6 +731,18 @@ async def update_smart_link(
729
  book.buy_url = str(body["buy_url"])[:1000] if body["buy_url"] else None
730
  if "preview_url" in body:
731
  book.preview_url = str(body["preview_url"])[:1000] if body["preview_url"] else None
 
 
 
 
 
 
 
 
 
 
 
 
732
  await db.commit()
733
  return {"message": "Smart link updated"}
734
 
@@ -756,16 +770,9 @@ async def get_token_usage(
756
  if not access:
757
  return {"budget": 0, "used": 0, "remaining": 0, "percent_used": 0}
758
 
759
- budget = access.token_budget or 0
760
- used = access.tokens_used or 0
761
- remaining = max(0, budget - used)
762
- pct = round(used / budget * 100, 1) if budget > 0 else 0
763
- return {
764
- "budget": budget,
765
- "used": used,
766
- "remaining": remaining,
767
- "percent_used": pct,
768
- }
769
 
770
 
771
  # ══════════════════════════════════════════════════════════════════════════════
 
698
  exp = exp.replace(tzinfo=None)
699
  days_remaining = max(0, (exp - now).days) if exp else 0
700
 
701
+ from app.services.token_budget import tokens_remaining
702
+
703
  return {
704
  "token": token,
705
  "grant_id": access.id,
706
  "plan": access.plan,
707
  "expires_at": access.expires_at.isoformat(),
708
  "days_remaining": days_remaining,
709
+ "tokens_remaining": tokens_remaining(access),
710
  }
711
 
712
 
 
731
  book.buy_url = str(body["buy_url"])[:1000] if body["buy_url"] else None
732
  if "preview_url" in body:
733
  book.preview_url = str(body["preview_url"])[:1000] if body["preview_url"] else None
734
+
735
+ from app.repositories.link_repo import LinkRepository
736
+
737
+ link_repo = LinkRepository(db)
738
+ await link_repo.upsert_for_book(
739
+ current_user.id,
740
+ book_id,
741
+ {
742
+ "purchase_url": book.buy_url,
743
+ "preview_url": book.preview_url,
744
+ },
745
+ )
746
  await db.commit()
747
  return {"message": "Smart link updated"}
748
 
 
770
  if not access:
771
  return {"budget": 0, "used": 0, "remaining": 0, "percent_used": 0}
772
 
773
+ from app.services.token_budget import usage_summary
774
+
775
+ return usage_summary(access)
 
 
 
 
 
 
 
776
 
777
 
778
  # ══════════════════════════════════════════════════════════════════════════════
app/core/access/subscription.py CHANGED
@@ -105,6 +105,9 @@ async def validate_subscription_token(
105
  if not author or not author.is_active:
106
  raise SubscriptionNotFoundError()
107
 
 
 
 
108
  return author
109
 
110
 
 
105
  if not author or not author.is_active:
106
  raise SubscriptionNotFoundError()
107
 
108
+ if not author.chatbot_is_active:
109
+ raise AccessRevokedError("Account suspended")
110
+
111
  return author
112
 
113
 
app/services/analytics_core/tracker.py CHANGED
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
11
 
12
  from app.models.analytics import AnalyticsEvent
13
  from app.models.base import generate_uuid
 
 
14
 
15
  logger = structlog.get_logger(__name__)
16
 
@@ -37,7 +39,6 @@ async def track_turn(
37
  """
38
  try:
39
  from datetime import datetime, timezone
40
- from app.models.chat_message import ChatMessage
41
 
42
  # Save messages to DB
43
  user_msg = ChatMessage(
@@ -87,12 +88,10 @@ async def track_turn(
87
  )
88
  db.add(event)
89
 
90
- # Increment token usage in Redis for budget tracking
91
- token_key = f"tokens:{author_id}:current"
92
- await redis.incrby(token_key, result.prompt_tokens + result.completion_tokens)
93
- await redis.expire(token_key, 32 * 24 * 3600) # 32-day TTL
94
 
95
- logger.debug("Turn tracked", session_id=session_id, tokens=result.prompt_tokens + result.completion_tokens)
96
 
97
  except Exception as e:
98
  logger.error("Analytics tracking failed (non-fatal)", error=str(e))
 
11
 
12
  from app.models.analytics import AnalyticsEvent
13
  from app.models.base import generate_uuid
14
+ from app.models.chat_session import ChatMessage
15
+ from app.services.token_budget import record_token_usage
16
 
17
  logger = structlog.get_logger(__name__)
18
 
 
39
  """
40
  try:
41
  from datetime import datetime, timezone
 
42
 
43
  # Save messages to DB
44
  user_msg = ChatMessage(
 
88
  )
89
  db.add(event)
90
 
91
+ total_tokens = result.prompt_tokens + result.completion_tokens
92
+ await record_token_usage(db, redis, author_id, total_tokens)
 
 
93
 
94
+ logger.debug("Turn tracked", session_id=session_id, tokens=total_tokens)
95
 
96
  except Exception as e:
97
  logger.error("Analytics tracking failed (non-fatal)", error=str(e))
app/services/prompter.py CHANGED
@@ -51,6 +51,28 @@ All templates use Python .format() for variable injection.
51
  #
52
  # ─── Master Chat System Prompt ────────────────────────────────────────────────
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  MASTER_SYSTEM_PROMPT = """You are {bot_name} — {author_name}'s dedicated book advisor for "{book_title}".
55
  You are NOT an AI assistant. You are this author's expert representative.
56
 
@@ -62,7 +84,7 @@ RULES YOU MUST FOLLOW
62
  3. SELL THROUGH INTRIGUE: Your job is to entertain and make them WANT to buy — not to replace the book.
63
  4. PLAIN TEXT: No markdown, no bullet lists, no headers.
64
  5. CONTEXT ONLY: Use [RETRIEVED CONTEXT] only. Never invent facts.
65
- 6. TONE: Warm, confident, human — like a friend who loved this book and wants them to feel the same.
66
 
67
  WHAT TO DO
68
  ══════════
 
51
  #
52
  # ─── Master Chat System Prompt ────────────────────────────────────────────────
53
 
54
+ RESPONSE_STYLE_INSTRUCTIONS: dict[str, str] = {
55
+ "balanced": (
56
+ "Warm, confident, and human — like a friend who loved this book "
57
+ "and wants them to feel the same."
58
+ ),
59
+ "formal": (
60
+ "Polished and professional — measured vocabulary, respectful tone, no slang."
61
+ ),
62
+ "casual": (
63
+ "Relaxed and conversational — friendly peer vibe, contractions welcome."
64
+ ),
65
+ "enthusiastic": (
66
+ "Upbeat and genuinely excited — share energy without overselling or hype."
67
+ ),
68
+ }
69
+
70
+
71
+ def get_response_style_instruction(style: str) -> str:
72
+ """Return tone guidance for the author's selected response style."""
73
+ return RESPONSE_STYLE_INSTRUCTIONS.get(style, RESPONSE_STYLE_INSTRUCTIONS["balanced"])
74
+
75
+
76
  MASTER_SYSTEM_PROMPT = """You are {bot_name} — {author_name}'s dedicated book advisor for "{book_title}".
77
  You are NOT an AI assistant. You are this author's expert representative.
78
 
 
84
  3. SELL THROUGH INTRIGUE: Your job is to entertain and make them WANT to buy — not to replace the book.
85
  4. PLAIN TEXT: No markdown, no bullet lists, no headers.
86
  5. CONTEXT ONLY: Use [RETRIEVED CONTEXT] only. Never invent facts.
87
+ 6. TONE ({response_style}): {tone_instruction}
88
 
89
  WHAT TO DO
90
  ══════════
app/services/rag_pipeline.py CHANGED
@@ -49,6 +49,7 @@ from app.services.prompter import (
49
  NO_CONTEXT_RESPONSE, HALLUCINATION_FALLBACK_RESPONSE,
50
  GREETING_RESPONSE, CATALOG_RESPONSE, BOOK_SELECTED_RESPONSE,
51
  FULL_STORY_RESPONSE,
 
52
  )
53
  from app.services.reranker import rerank_chunks
54
  from app.services.vector_store import retrieve_chunks
@@ -297,6 +298,7 @@ async def run_pipeline(
297
  interest_tags_str = ", ".join(session_context.interest_tags[:10]) or "None detected yet"
298
  book_title = _selected_book_title(active_books, session_context.selected_book_id)
299
 
 
300
  system_prompt = MASTER_SYSTEM_PROMPT.format(
301
  bot_name=author.bot_name,
302
  author_name=author.full_name or "the author",
@@ -305,6 +307,8 @@ async def run_pipeline(
305
  interest_tags=interest_tags_str,
306
  context=context_str,
307
  history=history_str,
 
 
308
  )
309
 
310
  user_content = query
@@ -495,8 +499,15 @@ async def _get_book_links(
495
  try:
496
  link_repo = LinkRepository(db)
497
  link = await link_repo.get_for_book(book_id, author_id)
498
- if link:
499
  return link.purchase_url, link.preview_url
 
 
 
 
 
 
 
500
  except Exception:
501
  pass
502
  return None, None
@@ -768,7 +779,7 @@ async def _no_context_response(
768
  return await _catalog_response(author, books, session_context, db, start_ms)
769
 
770
  default_robotic = "I want to give you the most accurate answer"
771
- fallback = author.fallback_message or ""
772
  if fallback and default_robotic not in fallback:
773
  text = fallback
774
  else:
 
49
  NO_CONTEXT_RESPONSE, HALLUCINATION_FALLBACK_RESPONSE,
50
  GREETING_RESPONSE, CATALOG_RESPONSE, BOOK_SELECTED_RESPONSE,
51
  FULL_STORY_RESPONSE,
52
+ get_response_style_instruction,
53
  )
54
  from app.services.reranker import rerank_chunks
55
  from app.services.vector_store import retrieve_chunks
 
298
  interest_tags_str = ", ".join(session_context.interest_tags[:10]) or "None detected yet"
299
  book_title = _selected_book_title(active_books, session_context.selected_book_id)
300
 
301
+ style = author.response_style or "balanced"
302
  system_prompt = MASTER_SYSTEM_PROMPT.format(
303
  bot_name=author.bot_name,
304
  author_name=author.full_name or "the author",
 
307
  interest_tags=interest_tags_str,
308
  context=context_str,
309
  history=history_str,
310
+ response_style=style,
311
+ tone_instruction=get_response_style_instruction(style),
312
  )
313
 
314
  user_content = query
 
499
  try:
500
  link_repo = LinkRepository(db)
501
  link = await link_repo.get_for_book(book_id, author_id)
502
+ if link and (link.purchase_url or link.preview_url):
503
  return link.purchase_url, link.preview_url
504
+
505
+ from app.repositories.book_repo import BookRepository
506
+
507
+ book_repo = BookRepository(db)
508
+ book = await book_repo.get_by_id(book_id)
509
+ if book and book.author_id == author_id:
510
+ return book.buy_url, book.preview_url
511
  except Exception:
512
  pass
513
  return None, None
 
779
  return await _catalog_response(author, books, session_context, db, start_ms)
780
 
781
  default_robotic = "I want to give you the most accurate answer"
782
+ fallback = (author.fallback_message or "").strip()
783
  if fallback and default_robotic not in fallback:
784
  text = fallback
785
  else:
app/services/superadmin_service.py CHANGED
@@ -25,6 +25,7 @@ from app.models.user import User
25
  from app.repositories.access_repo import AccessRepository
26
  from app.repositories.audit_repo import AuditRepository
27
  from app.repositories.user_repo import UserRepository
 
28
  from app.services.email_service import EmailService
29
 
30
  logger = structlog.get_logger(__name__)
@@ -261,7 +262,7 @@ class SuperAdminService:
261
  )
262
 
263
  # Clear any cached budget flags
264
- await self._redis.delete(f"budget_exhausted:{access.author_id}")
265
 
266
  # Audit log
267
  await self._audit.log(
@@ -314,7 +315,7 @@ class SuperAdminService:
314
  })
315
 
316
  # Clear budget exhausted flag if it was set
317
- await self._redis.delete(f"budget_exhausted:{access.author_id}")
318
 
319
  await self._audit.log(
320
  actor_id=actor.id,
@@ -387,7 +388,7 @@ class SuperAdminService:
387
  })
388
 
389
  # Clear budget exhausted flag in Redis
390
- await self._redis.delete(f"budget_exhausted:{access.author_id}")
391
 
392
  await self._audit.log(
393
  actor_id=actor.id,
 
25
  from app.repositories.access_repo import AccessRepository
26
  from app.repositories.audit_repo import AuditRepository
27
  from app.repositories.user_repo import UserRepository
28
+ from app.services.token_budget import clear_budget_exhausted, clear_token_usage_counters
29
  from app.services.email_service import EmailService
30
 
31
  logger = structlog.get_logger(__name__)
 
262
  )
263
 
264
  # Clear any cached budget flags
265
+ await clear_budget_exhausted(self._redis, access.author_id)
266
 
267
  # Audit log
268
  await self._audit.log(
 
315
  })
316
 
317
  # Clear budget exhausted flag if it was set
318
+ await clear_budget_exhausted(self._redis, access.author_id)
319
 
320
  await self._audit.log(
321
  actor_id=actor.id,
 
388
  })
389
 
390
  # Clear budget exhausted flag in Redis
391
+ await clear_token_usage_counters(self._redis, access.author_id)
392
 
393
  await self._audit.log(
394
  actor_id=actor.id,
app/services/token_budget.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Author RAG Chatbot SaaS — Token budget helpers.
2
+
3
+ Single source of truth for budget math and post-chat usage updates.
4
+ """
5
+
6
+ from app.models.client_access import ClientAccess
7
+
8
+ BUDGET_EXHAUSTED_PREFIX = "budget_exhausted:"
9
+ TOKENS_REDIS_PREFIX = "tokens:"
10
+
11
+
12
+ def total_budget(access: ClientAccess) -> int:
13
+ """Total available tokens including bonus grants."""
14
+ return (access.token_budget or 0) + (access.bonus_tokens or 0)
15
+
16
+
17
+ def tokens_remaining(access: ClientAccess) -> int:
18
+ """Tokens left in the current billing period."""
19
+ return max(0, total_budget(access) - (access.tokens_used or 0))
20
+
21
+
22
+ def percent_used(access: ClientAccess) -> float:
23
+ """Percentage of total budget consumed."""
24
+ budget = total_budget(access)
25
+ if budget <= 0:
26
+ return 0.0
27
+ return round((access.tokens_used or 0) / budget * 100, 1)
28
+
29
+
30
+ def usage_summary(access: ClientAccess) -> dict:
31
+ """Standard token usage dict for API responses."""
32
+ budget = total_budget(access)
33
+ used = access.tokens_used or 0
34
+ return {
35
+ "budget": budget,
36
+ "used": used,
37
+ "remaining": max(0, budget - used),
38
+ "percent_used": percent_used(access),
39
+ }
40
+
41
+
42
+ async def record_token_usage(
43
+ db,
44
+ redis,
45
+ author_id: str,
46
+ token_count: int,
47
+ ) -> None:
48
+ """Increment usage counters and set budget_exhausted when at 100%.
49
+
50
+ Updates client_access.tokens_used (DB) and tokens:{author_id}:current (Redis).
51
+ """
52
+ if token_count <= 0:
53
+ return
54
+
55
+ from app.repositories.access_repo import AccessRepository
56
+
57
+ token_key = f"{TOKENS_REDIS_PREFIX}{author_id}:current"
58
+ await redis.incrby(token_key, token_count)
59
+ await redis.expire(token_key, 32 * 24 * 3600)
60
+
61
+ access_repo = AccessRepository(db)
62
+ access = await access_repo.get_active_for_author(author_id)
63
+ if not access:
64
+ return
65
+
66
+ access.tokens_used = (access.tokens_used or 0) + token_count
67
+ budget = total_budget(access)
68
+
69
+ if budget > 0 and access.tokens_used >= budget:
70
+ await redis.set(f"{BUDGET_EXHAUSTED_PREFIX}{author_id}", "1", ex=32 * 24 * 3600)
71
+
72
+
73
+ async def clear_budget_exhausted(redis, author_id: str) -> None:
74
+ """Clear the budget exhausted flag (e.g. after bonus tokens)."""
75
+ await redis.delete(f"{BUDGET_EXHAUSTED_PREFIX}{author_id}")
76
+
77
+
78
+ async def clear_token_usage_counters(redis, author_id: str) -> None:
79
+ """Clear Redis usage counters after SuperAdmin reset."""
80
+ await redis.delete(
81
+ f"{BUDGET_EXHAUSTED_PREFIX}{author_id}",
82
+ f"{TOKENS_REDIS_PREFIX}{author_id}:current",
83
+ )
app/services/upsell_engine.py CHANGED
@@ -73,6 +73,8 @@ class UpsellEngine:
73
  strategy: str,
74
  ) -> bool:
75
  """Determine if a purchase link button should be shown."""
 
 
76
  if intent in ("purchase_intent", "full_story_request"):
77
  return True
78
  if strategy == "DIRECT_CTA":
 
73
  strategy: str,
74
  ) -> bool:
75
  """Determine if a purchase link button should be shown."""
76
+ if intent == "complaint":
77
+ return False
78
  if intent in ("purchase_intent", "full_story_request"):
79
  return True
80
  if strategy == "DIRECT_CTA":
tests/TEST_PLAN.md ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Author RAG — Master Test Plan
2
+
3
+ **Version:** 1.0
4
+ **Date:** 2026-06-19
5
+ **Status:** Active — pre-implementation baseline
6
+ **Owner:** Engineering
7
+ **Scope:** Full platform — SuperAdmin, Author Admin, Widget/Chat, RAG pipeline, analytics, notifications
8
+
9
+ ---
10
+
11
+ ## 1. Purpose & Goals
12
+
13
+ This document defines the complete verification strategy for Author RAG before any feature is considered production-ready. The platform's success depends on **100% consistency** across:
14
+
15
+ - Token accounting (single source of truth, enforced everywhere)
16
+ - Conversion UX (Buy Book CTA visible when configured and appropriate)
17
+ - Human tone (no robotic fallbacks; personality settings honored)
18
+ - Role isolation (SuperAdmin, Author, Visitor)
19
+ - Analytics integrity (every turn tracked; failures never break chat)
20
+
21
+ ### 1.1 Known Critical Gaps (must pass after fix)
22
+
23
+ | ID | Gap | Impact | Primary Files |
24
+ |----|-----|--------|---------------|
25
+ | G-01 | `client_access.tokens_used` never incremented on chat | Admin & SuperAdmin show 0% usage | `tracker.py`, `admin/router.py`, `superadmin_service.py` |
26
+ | G-02 | Redis `tokens:{author_id}:current` incremented but never read | Parallel orphan counter | `tracker.py`, `subscription.py` |
27
+ | G-03 | `budget_exhausted:{author_id}` never set | Budget enforcement non-functional | `subscription.py`, `tracker.py` |
28
+ | G-04 | Admin `token-usage` ignores `bonus_tokens`; embed-token includes them | Inconsistent UI numbers | `admin/router.py` |
29
+ | G-05 | Smart Links save to `books.buy_url`; chat reads `links.purchase_url` | Buy Book button never appears | `admin/router.py`, `rag_pipeline.py`, `link_repo.py` |
30
+ | G-06 | `response_style` saved but not injected into prompts | Personality page is cosmetic | `prompter.py`, `rag_pipeline.py` |
31
+ | G-07 | Default fallback contains robotic phrase filtered by hack | Inconsistent fallback behavior | `rag_pipeline.py`, `user.py` |
32
+ | G-08 | `chatbot_is_active` not checked on chat | Suspended authors still chat | `subscription.py`, `chat.py` |
33
+ | G-09 | Token warning emails use placeholder `tokens_used=0` | Alerts never fire | `tasks/` |
34
+ | G-10 | No automated tests; CI runs `pytest \|\| true` | Regressions undetected | `.github/workflows/ci.yml` |
35
+
36
+ ---
37
+
38
+ ## 2. Test Strategy
39
+
40
+ ### 2.1 Pyramid
41
+
42
+ ```
43
+ ┌─────────────┐
44
+ │ E2E (5%) │ Playwright — widget + admin SPAs
45
+ ┌┴─────────────┴┐
46
+ │ Integration │ FastAPI TestClient + real Redis/DB (15%)
47
+ ┌┴───────────────┴┐
48
+ │ Unit (80%) │ Pipeline, upsell, formatter, token math
49
+ └─────────────────┘
50
+ ```
51
+
52
+ ### 2.2 Environments
53
+
54
+ | Environment | DB | Redis | OpenAI | Purpose |
55
+ |-------------|-----|-------|--------|---------|
56
+ | **Unit** | SQLite in-memory / mocks | fakeredis | Mocked | Fast CI, logic only |
57
+ | **Integration** | PostgreSQL test DB | Redis container | Mocked or recorded fixtures | API contracts, token flow |
58
+ | **Staging** | Production-like PG | Production-like Redis | Real (limited budget) | Pre-release smoke |
59
+ | **Production** | Live | Live | Live | Synthetic monitors only |
60
+
61
+ ### 2.3 Tooling
62
+
63
+ | Layer | Tool | Location |
64
+ |-------|------|----------|
65
+ | Unit / Integration | `pytest`, `pytest-asyncio`, `httpx`, `factory-boy` | `tests/unit/`, `tests/integration/` |
66
+ | BDD (acceptance) | `behave` | `tests/bdd/features/` (from `.agent/BDD_SPECS.md`) |
67
+ | E2E | Playwright (recommended) | `tests/e2e/` |
68
+ | Load | `locust` (optional) | `tests/load/` |
69
+ | Contract | OpenAPI schema validation | `tests/contract/` |
70
+
71
+ ### 2.4 CI Gate (target state)
72
+
73
+ ```yaml
74
+ # Fail CI on any test failure — remove `|| true`
75
+ pytest tests/ -v --cov=app --cov-fail-under=80
76
+ behave tests/bdd/features/
77
+ ```
78
+
79
+ ### 2.5 Test Data Fixtures
80
+
81
+ | Fixture | Description |
82
+ |---------|-------------|
83
+ | `superadmin_user` | Seeded from `SUPERADMIN_EMAIL` / `SUPERADMIN_PASSWORD` |
84
+ | `author_with_grant` | Author + active `client_access` + 500K token budget |
85
+ | `author_with_books` | 1–3 books in `ready` status with embeddings |
86
+ | `subscription_token` | Valid HMAC token for widget |
87
+ | `book_with_links` | `links` row with `purchase_url` + `preview_url` |
88
+ | `book_with_buy_url_only` | `books.buy_url` set, no `links` row (regression case) |
89
+
90
+ ---
91
+
92
+ ## 3. Architecture Verification Map
93
+
94
+ Every test suite maps to a subsystem. No subsystem ships without coverage.
95
+
96
+ ```
97
+ ┌──────────────────────────────────────────────────────────────────┐
98
+ │ Visitor (Widget) │
99
+ │ widget.js → /api/chat/{slug} → subscription validation │
100
+ └────────────────────────────┬─────────────────────────────────────┘
101
+
102
+ ┌────────────────────────────▼─────────────────────────────────────┐
103
+ │ RAG Pipeline (12 steps) │
104
+ │ guardrails → intent → session → rewrite → retrieve → rerank │
105
+ │ → context → LLM → faithfulness → boundary → upsell → format │
106
+ └────────────────────────────┬─────────────────────────────────────┘
107
+
108
+ ┌───────────────────┼───────────────────┐
109
+ ▼ ▼ ▼
110
+ analytics_core client_access links/books
111
+ (tracker, agg) (token budget) (purchase URLs)
112
+ │ │ │
113
+ ▼ ▼ ▼
114
+ Author Admin UI SuperAdmin UI Celery tasks
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 4. Test Suites
120
+
121
+ ### Suite A — Token Usage Consistency (P0 — BLOCKING)
122
+
123
+ **Objective:** One canonical token counter drives enforcement, admin UI, SuperAdmin UI, emails, and exports.
124
+
125
+ #### A.1 Single Source of Truth
126
+
127
+ | Test ID | Scenario | Steps | Expected |
128
+ |---------|----------|-------|----------|
129
+ | A-01 | Chat increments `tokens_used` | Send 1 chat message with known token usage (mock OpenAI returns `prompt_tokens=100, completion_tokens=50`) | `client_access.tokens_used` += 150 |
130
+ | A-02 | Chat increments Redis counter | Same as A-01 | `tokens:{author_id}:current` += 150 |
131
+ | A-03 | Counters stay in sync | Send 10 messages | `tokens_used` == Redis counter == sum of `chat_messages` tokens for period |
132
+ | A-04 | Regeneration counts tokens | Hallucination triggers regen (2 LLM calls) | Both calls' tokens counted |
133
+ | A-05 | Custom Q&A path counts tokens | Message hits custom Q&A override | Tokens still recorded if LLM called |
134
+ | A-06 | Failed LLM does not increment | OpenAI timeout / 500 | No token increment; error logged |
135
+ | A-07 | Analytics failure does not block chat | Mock DB write failure in `track_turn` | Visitor gets response; error logged at ERROR |
136
+
137
+ #### A.2 Budget Math (must be identical everywhere)
138
+
139
+ | Test ID | Formula Under Test | Surfaces |
140
+ |---------|-------------------|----------|
141
+ | A-10 | `total_budget = token_budget + bonus_tokens` | SuperAdmin grants, embed-token |
142
+ | A-11 | `remaining = total_budget - tokens_used` | Admin token page, embed-token, SuperAdmin |
143
+ | A-12 | `percent_used = tokens_used / total_budget * 100` | Admin gauge, SuperAdmin progress bar |
144
+ | A-13 | Bonus tokens increase remaining without changing expiry | SuperAdmin bonus modal |
145
+
146
+ **Consistency matrix (all must match after each chat turn):**
147
+
148
+ | Surface | Endpoint / Store | Includes Bonus? | Reads `tokens_used`? |
149
+ |---------|------------------|-----------------|----------------------|
150
+ | Author Token Usage page | `GET /api/admin/{slug}/token-usage` | MUST | MUST |
151
+ | Author Embed Token card | `GET /api/admin/{slug}/embed-token` | MUST | MUST |
152
+ | SuperAdmin Token Management | `GET /api/super/grants` | MUST | MUST |
153
+ | SuperAdmin Platform Analytics | `GET /api/super/health` or analytics | MUST | MUST |
154
+ | Subscription validation | `validate_subscription_token` | MUST | MUST |
155
+ | Analytics export CSV | `GET /api/admin/{slug}/export/analytics` | N/A (historical) | Aggregated from events |
156
+ | Weekly digest email | Celery task | MUST | MUST |
157
+
158
+ #### A.3 Budget Enforcement
159
+
160
+ | Test ID | Scenario | Expected |
161
+ |---------|----------|----------|
162
+ | A-20 | Usage at 79% | Chat works normally |
163
+ | A-21 | Usage at 80% | Warning email sent (if `notify_token_alerts=true`) within 60s |
164
+ | A-22 | Usage at 99% | Chat still works; UI shows red/warning state |
165
+ | A-23 | Usage at 100% | `budget_exhausted:{author_id}` set; next message returns graceful fallback (HTTP 200 per BDD), OpenAI NOT called |
166
+ | A-24 | SuperAdmin adds bonus at 100% | `budget_exhausted` cleared; chat resumes |
167
+ | A-25 | SuperAdmin reset tokens | `tokens_used=0`; policy on `bonus_tokens` documented and tested |
168
+ | A-26 | Extend subscription at 100% usage | Expiry extended; usage NOT reset unless explicitly reset |
169
+
170
+ #### A.4 SuperAdmin Token Operations
171
+
172
+ | Test ID | Action | Verify |
173
+ |---------|--------|--------|
174
+ | A-30 | Grant new subscription | `token_budget` set per plan; `tokens_used=0` |
175
+ | A-31 | Add bonus tokens | `bonus_tokens` incremented; audit log entry |
176
+ | A-32 | Reset token usage | `tokens_used=0`; Redis counters cleared; audit log |
177
+ | A-33 | Revoke subscription | Chat blocked (403); Redis blacklist set |
178
+ | A-34 | Token Management UI totals | Sum of all grants' `tokens_used` matches platform aggregate |
179
+
180
+ #### A.5 Author Admin Token Page
181
+
182
+ | Test ID | Scenario | Expected |
183
+ |---------|----------|----------|
184
+ | A-40 | Page load after 5 chats | Gauge shows non-zero `used` matching DB |
185
+ | A-41 | No active grant | Shows zeros with clear empty state (not error) |
186
+ | A-42 | Gauge color thresholds | <75% indigo, 75–90% amber, >90% red |
187
+ | A-43 | Matches embed-token card | Same `remaining` value on both pages |
188
+
189
+ ---
190
+
191
+ ### Suite B — Buy Book Button & Smart Links (P0 — BLOCKING)
192
+
193
+ **Objective:** When an author configures a purchase URL, the widget shows a "Buy Book" button at the correct times.
194
+
195
+ #### B.1 Data Model Unification
196
+
197
+ | Test ID | Scenario | Expected |
198
+ |---------|----------|----------|
199
+ | B-01 | Author saves Smart Link (`PUT /smart-links/{bookId}`) | `links.purchase_url` AND `books.buy_url` both set (or single source with fallback read) |
200
+ | B-02 | Author sets buy URL on book upload | URL persisted and available to chat |
201
+ | B-03 | `_get_book_links()` with only `books.buy_url` | Returns purchase URL (fallback) |
202
+ | B-04 | `_get_book_links()` with only `links` row | Returns purchase URL |
203
+ | B-05 | Preview URL | "Read Preview" button appears when configured (max 2 links) |
204
+
205
+ #### B.2 Upsell Engine — When Button Appears
206
+
207
+ | Test ID | Intent / Context | Turn | Expected `show_link` |
208
+ |---------|------------------|------|----------------------|
209
+ | B-10 | `purchase_intent` ("Where can I buy?") | 1 | `true` + Buy Book button in widget |
210
+ | B-11 | `full_story_request` | any | `true` |
211
+ | B-12 | `question`, book selected | 2+ | `true` (per `should_include_link`) |
212
+ | B-13 | `question`, high interest (≥0.45) | 2+ | `true` |
213
+ | B-14 | `complaint` | any | `false` — no buy button |
214
+ | B-15 | `greeting` | 1 | `false` (RECIPROCITY only, no button) |
215
+ | B-16 | Strategy `DIRECT_CTA` | any | `true` |
216
+ | B-17 | No purchase URL configured | any | `false` — no empty/broken button |
217
+ | B-18 | `show_link=true` but URL is `#` | any | Button must NOT render (invalid URL guard) |
218
+
219
+ #### B.3 Widget Rendering
220
+
221
+ | Test ID | Scenario | Expected |
222
+ |---------|----------|----------|
223
+ | B-20 | API returns `links: [{label:"Buy Book", url:"https://...", type:"purchase"}]` | Widget renders `<a class="ab-link-btn">` with icon |
224
+ | B-21 | Click Buy Book | `POST /track-click` fired with correct `session_id`, `book_id`, `link_type` |
225
+ | B-22 | Farewell on widget close | `POST /session/farewell` returns text + Buy Book link if URL exists |
226
+ | B-23 | Max 2 links | Purchase + Preview shown; no third link |
227
+ | B-24 | Mobile viewport (375px) | Buttons wrap correctly, tappable (min 44px touch target) |
228
+ | B-25 | `target="_blank" rel="noopener"` | Present on all link buttons |
229
+
230
+ #### B.4 End-to-End Conversion Path
231
+
232
+ | Test ID | Flow | Expected |
233
+ |---------|------|----------|
234
+ | B-30 | Configure URL → chat "where can I buy" → click button | Analytics records `link_shown=true` on message + click event |
235
+ | B-31 | Admin analytics dashboard | Link click count increments |
236
+ | B-32 | SuperAdmin platform analytics | Aggregate link clicks include this author |
237
+
238
+ ---
239
+
240
+ ### Suite C — Tone, Personality & Anti-Robotic (P0)
241
+
242
+ **Objective:** Responses feel human, warm, and on-brand; admin personality settings affect output.
243
+
244
+ #### C.1 Personality Settings Propagation
245
+
246
+ | Test ID | `response_style` | Expected in LLM system prompt |
247
+ |---------|------------------|--------------------------------|
248
+ | C-01 | `balanced` | Default warm tone instructions |
249
+ | C-02 | `formal` | Measured, professional vocabulary |
250
+ | C-03 | `casual` | Conversational, contractions allowed |
251
+ | C-04 | `enthusiastic` | Higher energy, exclamation sparingly |
252
+ | C-05 | Change style in admin → send chat | Next message reflects new style (verify via prompt capture mock) |
253
+
254
+ #### C.2 Robotic Phrase Elimination
255
+
256
+ | Test ID | Scenario | Must NOT contain |
257
+ |---------|----------|------------------|
258
+ | C-10 | No-context fallback | "I want to give you the most accurate answer" |
259
+ | C-11 | Hallucination fallback | "As an AI", "I'm an AI assistant" |
260
+ | C-12 | Jailbreak redirect | System prompt contents, rule explanations |
261
+ | C-13 | Budget exhausted message | Technical jargon, error codes |
262
+ | C-14 | Any normal response | Markdown `**bold**`, bullet lists, headers |
263
+
264
+ #### C.3 Custom Messages
265
+
266
+ | Test ID | Field | Scenario | Expected |
267
+ |---------|-------|----------|----------|
268
+ | C-20 | `welcome_message` | Session init | Widget shows custom welcome |
269
+ | C-21 | `fallback_message` | No retrieval context | Custom fallback used |
270
+ | C-22 | `out_of_scope_message` | Off-topic query | Custom or default redirect |
271
+ | C-23 | `bot_name` | All responses | Name in system prompt, not "AI assistant" |
272
+
273
+ #### C.4 Response Quality Constraints
274
+
275
+ | Test ID | Rule | Verification |
276
+ |---------|------|--------------|
277
+ | C-30 | Max ~75 words | `len(text.split()) <= 80` (buffer) |
278
+ | C-31 | Max 2 paragraphs | Formatter enforces |
279
+ | C-32 | Max 380 chars | Formatter truncates at sentence boundary |
280
+ | C-33 | No spoilers | Boundary test with "tell me the ending" |
281
+ | C-34 | No fabricated prices | Faithfulness guardrail test |
282
+
283
+ ---
284
+
285
+ ### Suite D — Authentication & Authorization (P0)
286
+
287
+ #### D.1 Auth Flows
288
+
289
+ | Test ID | Scenario | Expected |
290
+ |---------|----------|----------|
291
+ | D-01 | Author registration | 201, JWT returned, `role=author` |
292
+ | D-02 | Author login | JWT + refresh cookie |
293
+ | D-03 | Wrong password | 401 |
294
+ | D-04 | 5 failed logins | Account locked 30 min (per implementation) |
295
+ | D-05 | Refresh token rotation | New access token; old refresh invalidated |
296
+ | D-06 | SuperAdmin login | JWT with `role=superadmin` |
297
+ | D-07 | Author JWT on `/api/super/*` | 403 |
298
+ | D-08 | Expired JWT | 401 |
299
+
300
+ #### D.2 Tenant Isolation
301
+
302
+ | Test ID | Scenario | Expected |
303
+ |---------|----------|----------|
304
+ | D-10 | Author A requests Author B's books | 403 or empty (slug must match `current_user.id`) |
305
+ | D-11 | Author A uses B's slug in URL | Must NOT return B's data |
306
+ | D-12 | SuperAdmin lists all authors | Returns cross-tenant data (allowed) |
307
+
308
+ #### D.3 Subscription Token Security
309
+
310
+ | Test ID | Scenario | Expected |
311
+ |---------|----------|----------|
312
+ | D-20 | Valid token | Chat 200 |
313
+ | D-21 | Tampered HMAC | 403 |
314
+ | D-22 | Expired token | 403 |
315
+ | D-23 | Revoked token (Redis) | 403 immediately |
316
+ | D-24 | Revoked in DB | 403 |
317
+ | D-25 | Missing `X-Subscription-Token` header | 401/403 |
318
+
319
+ #### D.4 Suspension & Active State
320
+
321
+ | Test ID | Scenario | Expected |
322
+ |---------|----------|----------|
323
+ | D-30 | SuperAdmin suspends author | `chatbot_is_active=false` |
324
+ | D-31 | Suspended author — visitor chats | 403 with friendly message |
325
+ | D-32 | SuperAdmin unsuspends | Chat resumes |
326
+
327
+ ---
328
+
329
+ ### Suite E — RAG Pipeline (P1)
330
+
331
+ #### E.1 Pipeline Steps (integration)
332
+
333
+ | Test ID | Step | Verification |
334
+ |---------|------|--------------|
335
+ | E-01 | Guardrails | Jailbreak input → redirect, not LLM |
336
+ | E-02 | Intent classification | "where can I buy" → `purchase_intent` |
337
+ | E-03 | Book selector | 3 books + vague query → selector popup in response |
338
+ | E-04 | Single book | Auto-select, no popup |
339
+ | E-05 | Book name in query | Fuzzy match >0.85 → auto-select |
340
+ | E-06 | Faithfulness fail | Regenerate once; then safe fallback |
341
+ | E-07 | Boundary enforcer | Off-topic → redirect |
342
+ | E-08 | Custom Q&A override | Exact match returns canned answer |
343
+
344
+ #### E.2 Book Lifecycle
345
+
346
+ | Test ID | Scenario | Expected |
347
+ |---------|----------|----------|
348
+ | E-10 | Upload PDF | Status: uploading → parsing → chunking → embedding → ready |
349
+ | E-11 | File >50MB | Rejected before processing |
350
+ | E-12 | Unsupported .xlsx | 400 with supported formats listed |
351
+ | E-13 | Deactivate book mid-session | Next message offers remaining books |
352
+ | E-14 | Delete last active book | Chatbot disabled + dashboard warning |
353
+ | E-15 | Book `status != ready` | Graceful "still indexing" message |
354
+
355
+ ---
356
+
357
+ ### Suite F — Admin Panel (P1)
358
+
359
+ #### F.1 Dashboard & Analytics
360
+
361
+ | Test ID | Page | Verify |
362
+ |---------|------|--------|
363
+ | F-01 | Dashboard | Session count, recent activity load |
364
+ | F-02 | Analytics (30d) | Charts match `analytics_daily` rollups |
365
+ | F-03 | Sessions / Readers | Geo, fingerprint (no PII) |
366
+ | F-04 | Conversations | Transcript matches `chat_messages` |
367
+ | F-05 | Export CSV | Downloads valid CSV with correct columns |
368
+
369
+ #### F.2 Configuration Pages
370
+
371
+ | Test ID | Page | Verify |
372
+ |---------|------|--------|
373
+ | F-10 | Books CRUD | Create, edit, reorder, activate/deactivate |
374
+ | F-11 | Smart Links | Save, load, URL validation |
375
+ | F-12 | Widget Config | Theme, position persist to session init |
376
+ | F-13 | Personality | All fields save and reload |
377
+ | F-14 | Notifications toggles | `notify_token_alerts` respected by email tasks |
378
+ | F-15 | Custom Q&A | CRUD + priority ordering |
379
+ | F-16 | Embed Code | Valid script tag with live token |
380
+
381
+ #### F.3 UI Consistency
382
+
383
+ | Test ID | Check | Expected |
384
+ |---------|-------|----------|
385
+ | F-20 | All API errors | Toast/alert with human-readable message |
386
+ | F-21 | Loading states | Skeleton/spinner on every async page |
387
+ | F-22 | Empty states | Helpful copy, not blank screens |
388
+ | F-23 | Form validation | Client + server validation aligned |
389
+ | F-24 | Navigation | Active nav item highlights correctly |
390
+
391
+ ---
392
+
393
+ ### Suite G — SuperAdmin Panel (P1)
394
+
395
+ | Test ID | Feature | Verify |
396
+ |---------|---------|--------|
397
+ | G-01 | Authors list | Pagination, search, status badges |
398
+ | G-02 | Author detail | Books, grant status, token bar |
399
+ | G-03 | Grant access | Token generated, email sent, audit logged |
400
+ | G-04 | Revoke / extend / bonus / reset | Each writes audit log + correct DB state |
401
+ | G-05 | Audit log | Immutable, chronological, filterable |
402
+ | G-06 | Platform health | DB, Redis, Chroma status |
403
+ | G-07 | Announcements | Delivered to author notifications |
404
+ | G-08 | Token Management totals | Match sum of individual grants (see Suite A) |
405
+ | G-09 | Suspend / unsuspend | Immediate effect on chat (Suite D) |
406
+
407
+ ---
408
+
409
+ ### Suite H — Widget & Session (P1)
410
+
411
+ | Test ID | Scenario | Expected |
412
+ |---------|----------|----------|
413
+ | H-01 | Session init | `bot_name`, `welcome_message`, `widget_theme` applied |
414
+ | H-02 | Multi-turn history | Context preserved across turns |
415
+ | H-03 | Session reset | History cleared, new session ID |
416
+ | H-04 | Rating (1–5 stars) | Stored on `chat_sessions` |
417
+ | H-05 | Feedback submission | Stored and visible in admin |
418
+ | H-06 | SSE ingestion events | Progress events during book upload |
419
+ | H-07 | Rate limiting | 61st request/min per IP → 429 |
420
+ | H-08 | Widget <8KB | `widget.js` size budget met |
421
+ | H-09 | Offline / API error | User-friendly error bubble, retry option |
422
+
423
+ ---
424
+
425
+ ### Suite I — Email & Celery Tasks (P2)
426
+
427
+ | Test ID | Task | Trigger | Verify |
428
+ |---------|------|---------|--------|
429
+ | I-01 | Token 80% warning | Usage threshold | Email with correct numbers |
430
+ | I-02 | Subscription expiry 7d | Daily beat | Warning email |
431
+ | I-03 | Weekly digest (Monday 9am) | Beat + timezone | Sessions, top book, tokens, country |
432
+ | I-04 | Grant notification | SuperAdmin grant | Author receives access email |
433
+ | I-05 | Revocation notification | SuperAdmin revoke | Author notified within 5s |
434
+ | I-06 | Link health check | Scheduled | `purchase_url_ok` updated |
435
+ | I-07 | Analytics aggregation | Hourly | `analytics_daily` matches raw events |
436
+
437
+ ---
438
+
439
+ ### Suite J — Security & Edge Cases (P1)
440
+
441
+ | Test ID | Scenario | Expected |
442
+ |---------|----------|----------|
443
+ | J-01 | SQL injection in chat message | Sanitized; no DB error |
444
+ | J-02 | XSS in author `welcome_message` | Escaped in widget (`escHtml`) |
445
+ | J-03 | SSRF via buy URL | URL validation blocks internal IPs |
446
+ | J-04 | Path traversal on upload | Rejected |
447
+ | J-05 | CORS | Only configured origins |
448
+ | J-06 | Security headers | Present on all responses |
449
+ | J-07 | `/api/super/diag` | Must require auth (if not public by design) |
450
+ | J-08 | `/api/widget/token` | Must not issue tokens without author auth |
451
+ | J-09 | Concurrent chat + grant revoke | No race — revoke wins |
452
+ | J-10 | Redis down | Graceful degradation per layer (documented) |
453
+
454
+ ---
455
+
456
+ ## 5. Cross-Cutting Consistency Checklist
457
+
458
+ Run this checklist after **every** release candidate:
459
+
460
+ ### 5.1 Numeric Consistency
461
+
462
+ - [ ] Send exactly 1 chat message with known token count
463
+ - [ ] Verify `client_access.tokens_used`
464
+ - [ ] Verify Redis `tokens:{author_id}:current`
465
+ - [ ] Verify Author Admin → Token Usage page
466
+ - [ ] Verify Author Admin → Embed Token card
467
+ - [ ] Verify SuperAdmin → Token Management row
468
+ - [ ] Verify SuperAdmin → Platform Analytics total
469
+ - [ ] Verify `chat_messages` sum for session
470
+ - [ ] Verify `analytics_events` row for turn
471
+
472
+ ### 5.2 Buy Button Consistency
473
+
474
+ - [ ] Configure buy URL in Smart Links
475
+ - [ ] Ask "where can I buy this book?"
476
+ - [ ] Confirm `link_shown=true` in DB
477
+ - [ ] Confirm button visible in widget DOM
478
+ - [ ] Click button → analytics event recorded
479
+ - [ ] Close widget → farewell shows Buy Book
480
+
481
+ ### 5.3 Tone Consistency
482
+
483
+ - [ ] Set personality to `casual` → verify prompt
484
+ - [ ] Trigger no-context path → no robotic phrases
485
+ - [ ] Trigger hallucination fallback → warm safe message
486
+ - [ ] Verify no markdown in any response path
487
+
488
+ ---
489
+
490
+ ## 6. Edge Case Catalog
491
+
492
+ | Category | Edge Case | Expected Behavior |
493
+ |----------|-----------|-------------------|
494
+ | **Tokens** | Author with 0 budget grant | Clear error on grant; chat blocked |
495
+ | **Tokens** | Multiple rapid messages | Atomic increment; no lost counts |
496
+ | **Tokens** | Month rollover | Usage resets per billing period (define policy) |
497
+ | **Tokens** | SuperAdmin reset during active chat | Next message uses fresh budget |
498
+ | **Links** | URL with UTM params | Preserved exactly |
499
+ | **Links** | International domain (IDN) | Validated and stored |
500
+ | **Links** | HTTP vs HTTPS | HTTPS preferred; mixed content warning in admin |
501
+ | **Books** | 0 active books | Catalog setup message |
502
+ | **Books** | 50+ books | Selector pagination / scroll |
503
+ | **Chat** | Empty message | 400 validation error |
504
+ | **Chat** | 2000+ char message | Truncated or rejected gracefully |
505
+ | **Chat** | Unicode / emoji | Handled end-to-end |
506
+ | **Chat** | RTL language input | Widget displays correctly |
507
+ | **Session** | Same fingerprint, new session | Separate session IDs |
508
+ | **Auth** | Token refresh during admin action | Seamless retry |
509
+ | **Upload** | Duplicate SHA-256 | Skip/replace dialog |
510
+ | **Upload** | Network drop mid-upload | Resume from chunk (if implemented) |
511
+
512
+ ---
513
+
514
+ ## 7. Acceptance Criteria (Definition of Done)
515
+
516
+ A feature or fix is **DONE** only when:
517
+
518
+ 1. All P0 tests in the relevant suite pass in CI
519
+ 2. No new gaps introduced in the consistency matrix (Section 5)
520
+ 3. BDD scenario added to `tests/bdd/features/` BEFORE merge (per `.agent/RULES.md`)
521
+ 4. Unit test coverage ≥80% on touched modules
522
+ 5. Manual smoke on staging: Author flow + Visitor flow + SuperAdmin flow
523
+ 6. Audit log entry verified for every SuperAdmin mutation
524
+ 7. Analytics event verified for every chat turn
525
+ 8. Documentation updated if API contract changes
526
+
527
+ ### Release Blockers (cannot ship if any fail)
528
+
529
+ - [ ] Suite A — all P0 tests green
530
+ - [ ] Suite B — all P0 tests green
531
+ - [ ] Suite C — all P0 tests green
532
+ - [ ] Suite D — D-10, D-11, D-30, D-31 green
533
+ - [ ] CI no longer uses `|| true`
534
+
535
+ ---
536
+
537
+ ## 8. Implementation Roadmap for Test Infrastructure
538
+
539
+ ### Phase 1 — Foundation (Week 1)
540
+
541
+ 1. Create `tests/conftest.py` with async DB, Redis, JWT helpers
542
+ 2. Add factories: `UserFactory`, `ClientAccessFactory`, `BookFactory`, `LinkFactory`
543
+ 3. Mock OpenAI client fixture returning deterministic token counts
544
+ 4. Fix CI: remove `|| true`; add PostgreSQL + Redis services
545
+
546
+ ### Phase 2 — P0 Unit Tests (Week 1–2)
547
+
548
+ 1. `test_token_tracking.py` — Suite A
549
+ 2. `test_upsell_engine.py` — Suite B (link logic)
550
+ 3. `test_formatter.py` — link injection, max links
551
+ 4. `test_prompter_personality.py` — Suite C
552
+ 5. `test_subscription_validation.py` — Suite D
553
+
554
+ ### Phase 3 — Integration Tests (Week 2–3)
555
+
556
+ 1. `test_chat_api.py` — full message flow with mocked LLM
557
+ 2. `test_admin_token_usage.py` — API response consistency
558
+ 3. `test_superadmin_grants.py` — grant/bonus/reset flows
559
+ 4. `test_smart_links_sync.py` — books ↔ links unification
560
+
561
+ ### Phase 4 — BDD & E2E (Week 3–4)
562
+
563
+ 1. Port `.agent/BDD_SPECS.md` → `tests/bdd/features/*.feature`
564
+ 2. Playwright: widget buy button visible + click
565
+ 3. Playwright: admin token gauge updates after chat
566
+
567
+ ### Phase 5 — Hardening (Ongoing)
568
+
569
+ 1. Synthetic production monitor: health + token math spot-check
570
+ 2. Load test: 60 req/min rate limit boundary
571
+ 3. Chaos: Redis restart during chat
572
+
573
+ ---
574
+
575
+ ## 9. Traceability Matrix
576
+
577
+ | User-Reported Issue | Test Suite | Test IDs |
578
+ |--------------------|------------|----------|
579
+ | Tokens not counted on admin | A | A-01, A-40, A-43 |
580
+ | Tokens not counted on superadmin | A | A-01, A-34, G-08 |
581
+ | Robotic tone | C | C-10, C-11, C-01–C-05 |
582
+ | Buy now button not in chat | B | B-01, B-10, B-20, B-30 |
583
+ | Personality settings ignored | C | C-01–C-05 |
584
+ | Budget not enforced | A | A-20–A-26 |
585
+ | Inconsistent token numbers | A | A-10–A-13, Section 5.1 |
586
+
587
+ ---
588
+
589
+ ## 10. Sign-Off
590
+
591
+ | Role | Name | Date | Signature |
592
+ |------|------|------|-----------|
593
+ | Engineering Lead | | | |
594
+ | QA | | | |
595
+ | Product | | | |
596
+
597
+ ---
598
+
599
+ *This plan should be treated as a living document. Update version and traceability matrix whenever gaps G-01 through G-10 are resolved or new features are added.*
tests/conftest.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared pytest fixtures for Author RAG test suite."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timedelta, timezone
6
+ from unittest.mock import AsyncMock
7
+
8
+ import pytest
9
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
10
+
11
+ # Test env before app imports
12
+ os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-only-32chars-min")
13
+ os.environ.setdefault("SUBSCRIPTION_SECRET", "test-subscription-secret-32chars!!")
14
+ os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
15
+ os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
16
+ os.environ.setdefault("OPENAI_API_KEY", "sk-test-fake")
17
+ os.environ.setdefault("CHROMA_PERSIST_DIR", "./test_chroma_tmp")
18
+
19
+ from app.models.base import Base, generate_uuid # noqa: E402
20
+ from app.models.book import Book # noqa: E402
21
+ from app.models.client_access import ClientAccess # noqa: E402
22
+ from app.models.user import User # noqa: E402
23
+ from app.core.security import hash_password # noqa: E402
24
+
25
+
26
+ @pytest.fixture
27
+ async def db_session():
28
+ """In-memory SQLite session with all tables."""
29
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
30
+ async with engine.begin() as conn:
31
+ await conn.run_sync(Base.metadata.create_all)
32
+
33
+ factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
34
+ async with factory() as session:
35
+ yield session
36
+
37
+ await engine.dispose()
38
+
39
+
40
+ @pytest.fixture
41
+ def mock_redis():
42
+ """Async Redis mock with incrby/expire tracking."""
43
+ redis = AsyncMock()
44
+ redis._counters: dict[str, int] = {}
45
+
46
+ async def incrby(key: str, amount: int) -> int:
47
+ redis._counters[key] = redis._counters.get(key, 0) + amount
48
+ return redis._counters[key]
49
+
50
+ redis.incrby = incrby
51
+ redis.expire = AsyncMock(return_value=True)
52
+ redis.exists = AsyncMock(return_value=0)
53
+ redis.delete = AsyncMock(return_value=1)
54
+ return redis
55
+
56
+
57
+ @pytest.fixture
58
+ async def author_user(db_session: AsyncSession):
59
+ """Create an author user in the test DB."""
60
+ user = User(
61
+ id=generate_uuid(),
62
+ email="author@test.example",
63
+ password_hash=hash_password("TestPass123!"),
64
+ role="author",
65
+ full_name="Test Author",
66
+ response_style="casual",
67
+ )
68
+ db_session.add(user)
69
+ await db_session.commit()
70
+ await db_session.refresh(user)
71
+ return user
72
+
73
+
74
+ @pytest.fixture
75
+ async def client_access(db_session: AsyncSession, author_user: User):
76
+ """Active subscription grant for the author."""
77
+ now = datetime.now(timezone.utc)
78
+ access = ClientAccess(
79
+ id=generate_uuid(),
80
+ author_id=author_user.id,
81
+ granted_by=author_user.id,
82
+ plan="pro",
83
+ granted_at=now,
84
+ expires_at=now + timedelta(days=30),
85
+ token_hash="a" * 64,
86
+ token_budget=500_000,
87
+ tokens_used=0,
88
+ bonus_tokens=100_000,
89
+ )
90
+ db_session.add(access)
91
+ await db_session.commit()
92
+ await db_session.refresh(access)
93
+ return access
94
+
95
+
96
+ @pytest.fixture
97
+ async def book_with_buy_url(db_session: AsyncSession, author_user: User):
98
+ """Book with buy_url on books table only (no links row)."""
99
+ book = Book(
100
+ id=generate_uuid(),
101
+ author_id=author_user.id,
102
+ title="Test Book",
103
+ status="ready",
104
+ buy_url="https://store.example.com/buy",
105
+ preview_url="https://store.example.com/preview",
106
+ )
107
+ db_session.add(book)
108
+ await db_session.commit()
109
+ await db_session.refresh(book)
110
+ return book
111
+
112
+
113
+ @dataclass
114
+ class FakePipelineResult:
115
+ """Minimal stand-in for RAG PipelineResult in tracker tests."""
116
+
117
+ response: dict = field(default_factory=lambda: {"text": "Hello", "links": [], "has_links": False})
118
+ intent: str = "question"
119
+ intent_confidence: float = 0.9
120
+ faithfulness_score: float = 1.0
121
+ hallucination_detected: bool = False
122
+ boundary_triggered: bool = False
123
+ upsell_strategy: str | None = "CURIOSITY_GAP"
124
+ link_shown: bool = False
125
+ prompt_tokens: int = 100
126
+ completion_tokens: int = 50
127
+ response_ms: int = 200
128
+ top_book_ids: list = field(default_factory=list)
tests/pytest.ini ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
4
+ python_files = test_*.py
5
+ markers =
6
+ known_gap: Known implementation gap documented in TEST_PLAN.md (expected to fail until fixed)
7
+ p0: Priority 0 blocking tests
tests/results/RESULTS.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Author RAG — Test Execution Results
2
+
3
+ **Run date:** 2026-06-19 (post-fix)
4
+ **Command:** `python -m pytest tests/unit/ -v --tb=short --junitxml=tests/results/junit.xml`
5
+ **Verdict:** **PASS — 25/25 (100%)**
6
+
7
+ ---
8
+
9
+ ## Summary
10
+
11
+ | Metric | Value |
12
+ |--------|-------|
13
+ | **Total tests** | 25 |
14
+ | **Passed** | 25 |
15
+ | **Failed** | 0 |
16
+ | **Pass rate** | 100% |
17
+
18
+ ---
19
+
20
+ ## Fixes Applied
21
+
22
+ | Gap | Fix |
23
+ |-----|-----|
24
+ | G-01 Token counting | `record_token_usage()` updates `client_access.tokens_used` + Redis |
25
+ | G-03 Budget enforcement | Sets `budget_exhausted:{author_id}` at 100% usage |
26
+ | G-04 Token math | Unified via `app/services/token_budget.py` (includes bonus_tokens) |
27
+ | G-05 Buy Book URLs | Smart links sync to `links` table; `_get_book_links()` falls back to `books.buy_url` |
28
+ | G-06 Personality | `{response_style}` + tone instructions in `MASTER_SYSTEM_PROMPT` |
29
+ | Tracker import | `ChatMessage` imported from `app.models.chat_session` |
30
+ | Complaint upsell | No buy link on `complaint` intent |
31
+ | Suspension | `chatbot_is_active` checked in subscription validation |
32
+
33
+ ---
34
+
35
+ ## Artifacts
36
+
37
+ - [`junit.xml`](junit.xml)
38
+ - [`pytest_output.log`](pytest_output.log)
39
+ - [`summary.json`](summary.json)
tests/results/junit.xml ADDED
@@ -0,0 +1 @@
 
 
1
+ <?xml version="1.0" encoding="utf-8"?><testsuites name="pytest tests"><testsuite name="pytest" errors="0" failures="0" skipped="0" tests="25" time="32.019" timestamp="2026-06-19T17:14:51.537269+05:00" hostname="DESKTOP-F453HUK"><testcase classname="unit.test_suite_a_token_consistency" name="test_admin_token_usage_includes_bonus_tokens" time="0.398" /><testcase classname="unit.test_suite_a_token_consistency" name="test_admin_and_embed_remaining_match" time="0.323" /><testcase classname="unit.test_suite_a_token_consistency" name="test_track_turn_increments_client_access_tokens_used" time="0.341" /><testcase classname="unit.test_suite_a_token_consistency" name="test_track_turn_increments_redis_counter" time="0.429" /><testcase classname="unit.test_suite_a_token_consistency" name="test_superadmin_and_admin_budget_formula_match" time="0.343" /><testcase classname="unit.test_suite_b_formatter" name="test_purchase_link_rendered_with_buy_book_label" time="0.002" /><testcase classname="unit.test_suite_b_formatter" name="test_no_links_when_show_link_false" time="0.002" /><testcase classname="unit.test_suite_b_formatter" name="test_max_two_links_purchase_and_preview" time="0.001" /><testcase classname="unit.test_suite_b_formatter" name="test_no_link_when_purchase_url_missing" time="0.001" /><testcase classname="unit.test_suite_b_formatter" name="test_markdown_stripped_from_response" time="0.001" /><testcase classname="unit.test_suite_b_smart_links" name="test_get_book_links_falls_back_to_books_buy_url" time="0.328" /><testcase classname="unit.test_suite_b_smart_links" name="test_smart_links_upsert_creates_links_row" time="0.328" /><testcase classname="unit.test_suite_b_upsell" name="test_purchase_intent_always_shows_link" time="0.003" /><testcase classname="unit.test_suite_b_upsell" name="test_complaint_never_shows_link" time="0.001" /><testcase classname="unit.test_suite_b_upsell" name="test_turn_two_with_selected_book_shows_link" time="0.001" /><testcase classname="unit.test_suite_b_upsell" name="test_greeting_turn_one_no_link" time="0.001" /><testcase classname="unit.test_suite_b_upsell" name="test_full_story_request_shows_link" time="0.001" /><testcase classname="unit.test_suite_c_tone" name="test_default_fallback_responses_not_robotic[I want to give you the most accurate answer]" time="0.001" /><testcase classname="unit.test_suite_c_tone" name="test_default_fallback_responses_not_robotic[As an AI]" time="0.003" /><testcase classname="unit.test_suite_c_tone" name="test_default_fallback_responses_not_robotic[I'm an AI assistant]" time="0.002" /><testcase classname="unit.test_suite_c_tone" name="test_default_fallback_responses_not_robotic[language model]" time="0.001" /><testcase classname="unit.test_suite_c_tone" name="test_response_style_injected_into_system_prompt" time="0.001" /><testcase classname="unit.test_suite_c_tone" name="test_master_prompt_requires_human_tone" time="0.001" /><testcase classname="unit.test_suite_d_subscription" name="test_record_token_usage_sets_budget_exhausted_at_limit" time="0.002" /><testcase classname="unit.test_suite_d_subscription" name="test_subscription_validates_budget_exhausted_redis_key" time="0.004" /></testsuite></testsuites>
tests/results/summary.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run_date": "2026-06-19",
3
+ "post_fix": true,
4
+ "summary": {
5
+ "total": 25,
6
+ "passed": 25,
7
+ "failed": 0,
8
+ "pass_rate_percent": 100.0,
9
+ "verdict": "PASS"
10
+ }
11
+ }
tests/unit/__init__.py ADDED
File without changes
tests/unit/test_suite_a_token_consistency.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite A — Token usage consistency (P0)."""
2
+
3
+ import pytest
4
+ from sqlalchemy import select
5
+
6
+ from app.models.client_access import ClientAccess
7
+ from app.services.analytics_core.tracker import track_turn
8
+ from app.services.token_budget import tokens_remaining, total_budget, usage_summary
9
+ from tests.conftest import FakePipelineResult
10
+
11
+
12
+ @pytest.mark.p0
13
+ def test_admin_token_usage_includes_bonus_tokens(client_access):
14
+ """A-10: Admin token-usage includes bonus_tokens in budget."""
15
+ response = usage_summary(client_access)
16
+ expected_total = total_budget(client_access)
17
+ assert response["budget"] == expected_total
18
+ assert response["remaining"] == expected_total - client_access.tokens_used
19
+
20
+
21
+ @pytest.mark.p0
22
+ def test_admin_and_embed_remaining_match(client_access):
23
+ """A-43: Token Usage page and Embed Token card must show same remaining."""
24
+ admin = usage_summary(client_access)
25
+ embed_remaining = tokens_remaining(client_access)
26
+ assert admin["remaining"] == embed_remaining
27
+
28
+
29
+ @pytest.mark.p0
30
+ @pytest.mark.asyncio
31
+ async def test_track_turn_increments_client_access_tokens_used(
32
+ db_session, mock_redis, author_user, client_access
33
+ ):
34
+ """A-01: Chat increments client_access.tokens_used."""
35
+ result = FakePipelineResult(prompt_tokens=100, completion_tokens=50)
36
+ await track_turn(
37
+ db=db_session,
38
+ redis=mock_redis,
39
+ session_id="session-1",
40
+ author_id=author_user.id,
41
+ book_id=None,
42
+ user_message="Hello",
43
+ result=result,
44
+ )
45
+ await db_session.commit()
46
+
47
+ row = await db_session.scalar(
48
+ select(ClientAccess).where(ClientAccess.id == client_access.id)
49
+ )
50
+ assert row.tokens_used == 150
51
+
52
+
53
+ @pytest.mark.p0
54
+ @pytest.mark.asyncio
55
+ async def test_track_turn_increments_redis_counter(
56
+ db_session, mock_redis, author_user, client_access
57
+ ):
58
+ """A-02: Redis tokens:{author_id}:current is incremented on chat."""
59
+ result = FakePipelineResult(prompt_tokens=100, completion_tokens=50)
60
+ await track_turn(
61
+ db=db_session,
62
+ redis=mock_redis,
63
+ session_id="session-1",
64
+ author_id=author_user.id,
65
+ book_id=None,
66
+ user_message="Hello",
67
+ result=result,
68
+ )
69
+ key = f"tokens:{author_user.id}:current"
70
+ assert mock_redis._counters.get(key) == 150
71
+
72
+
73
+ @pytest.mark.p0
74
+ def test_superadmin_and_admin_budget_formula_match(client_access):
75
+ """Consistency matrix: SuperAdmin total_budget vs Admin budget must match."""
76
+ super_total = total_budget(client_access)
77
+ admin_budget = usage_summary(client_access)["budget"]
78
+ assert admin_budget == super_total
tests/unit/test_suite_b_formatter.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite B — Buy Book button & response formatter (P0)."""
2
+
3
+ import pytest
4
+
5
+ from app.services.formatter import ResponseFormatter
6
+
7
+
8
+ @pytest.fixture
9
+ def formatter():
10
+ return ResponseFormatter()
11
+
12
+
13
+ @pytest.mark.p0
14
+ def test_purchase_link_rendered_with_buy_book_label(formatter):
15
+ result = formatter.format(
16
+ response_text="Great question about the plot.",
17
+ purchase_url="https://store.example.com/book",
18
+ show_link=True,
19
+ )
20
+ assert result["has_links"] is True
21
+ assert len(result["links"]) == 1
22
+ assert result["links"][0]["label"] == "Buy Book"
23
+ assert result["links"][0]["type"] == "purchase"
24
+ assert result["links"][0]["url"] == "https://store.example.com/book"
25
+
26
+
27
+ @pytest.mark.p0
28
+ def test_no_links_when_show_link_false(formatter):
29
+ result = formatter.format(
30
+ response_text="Just an answer.",
31
+ purchase_url="https://store.example.com/book",
32
+ show_link=False,
33
+ )
34
+ assert result["has_links"] is False
35
+ assert result["links"] == []
36
+
37
+
38
+ @pytest.mark.p0
39
+ def test_max_two_links_purchase_and_preview(formatter):
40
+ result = formatter.format(
41
+ response_text="Check it out.",
42
+ purchase_url="https://store.example.com/buy",
43
+ preview_url="https://store.example.com/preview",
44
+ show_link=True,
45
+ )
46
+ assert len(result["links"]) == 2
47
+ types = {link["type"] for link in result["links"]}
48
+ assert types == {"purchase", "preview"}
49
+
50
+
51
+ @pytest.mark.p0
52
+ def test_no_link_when_purchase_url_missing(formatter):
53
+ result = formatter.format(
54
+ response_text="Answer without URL.",
55
+ purchase_url=None,
56
+ show_link=True,
57
+ )
58
+ assert result["has_links"] is False
59
+
60
+
61
+ @pytest.mark.p0
62
+ def test_markdown_stripped_from_response(formatter):
63
+ result = formatter.format(response_text="This is **bold** text.", show_link=False)
64
+ assert "**" not in result["text"]
65
+ assert "bold" in result["text"]
tests/unit/test_suite_b_smart_links.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite B — Smart Links data model (P0)."""
2
+
3
+ import pytest
4
+
5
+ from app.repositories.link_repo import LinkRepository
6
+ from app.services.rag_pipeline import _get_book_links
7
+
8
+
9
+ @pytest.mark.p0
10
+ @pytest.mark.asyncio
11
+ async def test_get_book_links_falls_back_to_books_buy_url(
12
+ db_session, author_user, book_with_buy_url
13
+ ):
14
+ """B-03: Chat resolves purchase URL from books.buy_url when links row missing."""
15
+ purchase, preview = await _get_book_links(
16
+ book_with_buy_url.id, author_user.id, db_session
17
+ )
18
+ assert purchase == "https://store.example.com/buy"
19
+ assert preview == "https://store.example.com/preview"
20
+
21
+
22
+ @pytest.mark.p0
23
+ @pytest.mark.asyncio
24
+ async def test_smart_links_upsert_creates_links_row(
25
+ db_session, author_user, book_with_buy_url
26
+ ):
27
+ """B-01: upsert_for_book syncs purchase_url to links table."""
28
+ repo = LinkRepository(db_session)
29
+ await repo.upsert_for_book(
30
+ author_user.id,
31
+ book_with_buy_url.id,
32
+ {
33
+ "purchase_url": "https://store.example.com/new-buy",
34
+ "preview_url": "https://store.example.com/new-preview",
35
+ },
36
+ )
37
+ await db_session.commit()
38
+
39
+ link = await repo.get_for_book(book_with_buy_url.id, author_user.id)
40
+ assert link is not None
41
+ assert link.purchase_url == "https://store.example.com/new-buy"
42
+ assert link.preview_url == "https://store.example.com/new-preview"
tests/unit/test_suite_b_upsell.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite B — Upsell engine link visibility rules (P0)."""
2
+
3
+ import pytest
4
+
5
+ from app.services.session_core.manager import SessionContext
6
+ from app.services.upsell_engine import UpsellEngine
7
+
8
+
9
+ @pytest.fixture
10
+ def engine():
11
+ return UpsellEngine()
12
+
13
+
14
+ @pytest.mark.p0
15
+ def test_purchase_intent_always_shows_link(engine):
16
+ ctx = SessionContext(session_id="s1", author_id="a1", turn_count=0)
17
+ assert engine.should_include_link("purchase_intent", ctx, "DIRECT_CTA") is True
18
+
19
+
20
+ @pytest.mark.p0
21
+ def test_complaint_never_shows_link(engine):
22
+ ctx = SessionContext(
23
+ session_id="s1",
24
+ author_id="a1",
25
+ turn_count=5,
26
+ selected_book_id="book-1",
27
+ interest_score=0.9,
28
+ )
29
+ strategy = engine.select_strategy("complaint", ctx)
30
+ assert engine.should_include_link("complaint", ctx, strategy) is False
31
+
32
+
33
+ @pytest.mark.p0
34
+ def test_turn_two_with_selected_book_shows_link(engine):
35
+ ctx = SessionContext(
36
+ session_id="s1",
37
+ author_id="a1",
38
+ turn_count=2,
39
+ selected_book_id="book-1",
40
+ interest_score=0.2,
41
+ )
42
+ strategy = engine.select_strategy("question", ctx)
43
+ assert engine.should_include_link("question", ctx, strategy) is True
44
+
45
+
46
+ @pytest.mark.p0
47
+ def test_greeting_turn_one_no_link(engine):
48
+ ctx = SessionContext(session_id="s1", author_id="a1", turn_count=0)
49
+ strategy = engine.select_strategy("greeting", ctx)
50
+ assert engine.should_include_link("greeting", ctx, strategy) is False
51
+
52
+
53
+ @pytest.mark.p0
54
+ def test_full_story_request_shows_link(engine):
55
+ ctx = SessionContext(session_id="s1", author_id="a1", turn_count=0)
56
+ strategy = engine.select_strategy("full_story_request", ctx)
57
+ assert strategy == "DIRECT_CTA"
58
+ assert engine.should_include_link("full_story_request", ctx, strategy) is True
tests/unit/test_suite_c_tone.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite C — Tone, personality, anti-robotic (P0)."""
2
+
3
+ import pytest
4
+
5
+ from app.services.prompter import (
6
+ HALLUCINATION_FALLBACK_RESPONSE,
7
+ MASTER_SYSTEM_PROMPT,
8
+ NO_CONTEXT_RESPONSE,
9
+ )
10
+ ROBOTIC_PHRASES = [
11
+ "I want to give you the most accurate answer",
12
+ "As an AI",
13
+ "I'm an AI assistant",
14
+ "language model",
15
+ ]
16
+
17
+
18
+ @pytest.mark.p0
19
+ @pytest.mark.parametrize("phrase", ROBOTIC_PHRASES)
20
+ def test_default_fallback_responses_not_robotic(phrase):
21
+ """C-10 / C-11: Built-in fallback strings must not sound robotic."""
22
+ for template in (NO_CONTEXT_RESPONSE, HALLUCINATION_FALLBACK_RESPONSE):
23
+ sample = template.format(book_title="Test Book")
24
+ assert phrase.lower() not in sample.lower()
25
+
26
+
27
+ @pytest.mark.p0
28
+ @pytest.mark.known_gap
29
+ def test_response_style_injected_into_system_prompt():
30
+ """C-01 / G-06: response_style must appear in MASTER_SYSTEM_PROMPT."""
31
+ assert "{response_style}" in MASTER_SYSTEM_PROMPT
32
+
33
+
34
+ @pytest.mark.p0
35
+ def test_master_prompt_requires_human_tone():
36
+ """C-30: System prompt instructs warm human tone."""
37
+ assert "Warm" in MASTER_SYSTEM_PROMPT or "warm" in MASTER_SYSTEM_PROMPT
38
+ assert "As an AI" in MASTER_SYSTEM_PROMPT # listed under WHAT NOT TO DO
tests/unit/test_suite_d_subscription.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite D — Subscription budget enforcement (P0)."""
2
+
3
+ import inspect
4
+
5
+ import pytest
6
+
7
+ from app.core.access import subscription as sub_mod
8
+ from app.services import token_budget as token_budget_mod
9
+
10
+
11
+ @pytest.mark.p0
12
+ def test_record_token_usage_sets_budget_exhausted_at_limit():
13
+ """A-23 / G-03: record_token_usage sets budget_exhausted when at 100%."""
14
+ source = inspect.getsource(token_budget_mod.record_token_usage)
15
+ assert "budget_exhausted" in source
16
+
17
+
18
+ @pytest.mark.p0
19
+ def test_subscription_validates_budget_exhausted_redis_key():
20
+ """D: Subscription layer checks budget_exhausted Redis key."""
21
+ source = inspect.getsource(sub_mod.validate_subscription_token)
22
+ assert "budget_exhausted" in source