merge: integrate book-donation-chorus-search into freeze branch for combined testing

This commit is contained in:
pyr0ball 2026-07-13 22:44:15 -07:00
commit b260ffcc46
18 changed files with 277 additions and 7 deletions

View file

@ -15,6 +15,7 @@ def create_item(
captured_at: datetime,
discord_message_id: str | None,
sender_id: str | None = None,
direction: str = "inbound",
) -> tuple[Item, bool]:
if discord_message_id is not None:
existing = session.scalar(
@ -29,6 +30,7 @@ def create_item(
captured_at=captured_at,
discord_message_id=discord_message_id,
sender_id=sender_id,
direction=direction,
stage="new",
)
session.add(item)
@ -66,8 +68,11 @@ def update_item(session: Session, item_id: int, **fields) -> Item | None:
new_type = fields.get("type", item.type)
new_stage = fields.get("stage", item.stage)
if "stage" in fields and not is_valid_stage(new_type, new_stage):
raise ValueError(f"'{new_stage}' is not a valid stage for type '{new_type}'")
new_direction = fields.get("direction", item.direction)
if "stage" in fields and not is_valid_stage(new_type, new_stage, new_direction):
raise ValueError(
f"'{new_stage}' is not a valid stage for type '{new_type}' direction '{new_direction}'"
)
for key, value in fields.items():
setattr(item, key, value)

View file

@ -14,6 +14,7 @@ class Item(Base):
sender_id: Mapped[str | None] = mapped_column(String, nullable=True)
type: Mapped[str | None] = mapped_column(String, nullable=True)
stage: Mapped[str] = mapped_column(String, nullable=False, default="new")
direction: Mapped[str] = mapped_column(String, nullable=False, default="inbound")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
follow_up_date: Mapped[date | None] = mapped_column(Date, nullable=True)
discord_message_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)

View file

@ -8,6 +8,7 @@ class ItemCreate(BaseModel):
captured_at: datetime
discord_message_id: str | None = None
sender_id: str | None = None
direction: str = "inbound"
class ItemUpdate(BaseModel):
@ -28,6 +29,7 @@ class ItemOut(BaseModel):
sender_id: str | None
type: str | None
stage: str
direction: str
notes: str | None
follow_up_date: date | None
discord_message_id: str | None

View file

@ -1,8 +1,14 @@
DONATION_STAGES = ["new", "replied", "pickup_scheduled", "picked_up", "logged_in_bookmark"]
OTHER_STAGES = ["new", "done"]
OUTBOUND_STAGES = [
"new", "reviewed", "outreach_drafted", "outreach_sent",
"replied", "pickup_scheduled", "picked_up", "logged_in_bookmark",
]
def valid_stages_for(item_type: str | None) -> list[str]:
def valid_stages_for(item_type: str | None, direction: str = "inbound") -> list[str]:
if direction == "outbound":
return OUTBOUND_STAGES
if item_type == "donation":
return DONATION_STAGES
if item_type == "other":
@ -10,5 +16,5 @@ def valid_stages_for(item_type: str | None) -> list[str]:
return ["new"]
def is_valid_stage(item_type: str | None, stage: str) -> bool:
return stage in valid_stages_for(item_type)
def is_valid_stage(item_type: str | None, stage: str, direction: str = "inbound") -> bool:
return stage in valid_stages_for(item_type, direction)

View file

@ -61,3 +61,37 @@ def test_update_item_rejects_invalid_stage_for_type(db_session):
def test_update_item_returns_none_for_missing_id(db_session):
assert crud.update_item(db_session, 9999, notes="x") is None
def test_create_item_defaults_direction_to_inbound(db_session):
item, _ = crud.create_item(
db_session, modality="bh_email", raw_content="x", captured_at=_now(),
discord_message_id="dir-1",
)
assert item.direction == "inbound"
def test_create_item_accepts_outbound_direction(db_session):
item, _ = crud.create_item(
db_session, modality="lead_found", raw_content="found post", captured_at=_now(),
discord_message_id="dir-2", direction="outbound",
)
assert item.direction == "outbound"
def test_update_item_validates_stage_against_outbound_direction(db_session):
item, _ = crud.create_item(
db_session, modality="lead_found", raw_content="x", captured_at=_now(),
discord_message_id="dir-3", direction="outbound",
)
updated = crud.update_item(db_session, item.id, stage="outreach_sent")
assert updated.stage == "outreach_sent"
def test_update_item_rejects_inbound_only_stage_for_outbound_item(db_session):
item, _ = crud.create_item(
db_session, modality="lead_found", raw_content="x", captured_at=_now(),
discord_message_id="dir-4", direction="outbound",
)
with pytest.raises(ValueError):
crud.update_item(db_session, item.id, stage="done")

View file

@ -68,3 +68,21 @@ def test_patch_invalid_stage_returns_422(client):
def test_get_missing_item_returns_404(client):
resp = client.get("/items/999999")
assert resp.status_code == 404
def test_create_item_with_outbound_direction(client):
resp = client.post("/items", json={
"modality": "lead_found", "raw_content": "found on Nextdoor",
"captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "out-1",
"direction": "outbound",
})
assert resp.status_code == 201
assert resp.json()["direction"] == "outbound"
def test_create_item_direction_defaults_to_inbound(client):
resp = client.post("/items", json={
"modality": "voice", "raw_content": "call",
"captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "out-2",
})
assert resp.json()["direction"] == "inbound"

View file

@ -33,3 +33,24 @@ def test_is_valid_stage_false_for_mismatched_type():
def test_is_valid_stage_false_for_unknown_value():
assert stages.is_valid_stage("donation", "archived") is False
def test_outbound_stages_in_order():
assert stages.OUTBOUND_STAGES == [
"new", "reviewed", "outreach_drafted", "outreach_sent",
"replied", "pickup_scheduled", "picked_up", "logged_in_bookmark",
]
def test_valid_stages_for_outbound_ignores_type():
assert stages.valid_stages_for("donation", direction="outbound") == stages.OUTBOUND_STAGES
assert stages.valid_stages_for(None, direction="outbound") == stages.OUTBOUND_STAGES
def test_valid_stages_for_inbound_unchanged():
assert stages.valid_stages_for("donation") == stages.DONATION_STAGES
def test_is_valid_stage_outbound_accepts_outreach_stages():
assert stages.is_valid_stage("donation", "outreach_sent", direction="outbound") is True
assert stages.is_valid_stage("donation", "outreach_sent", direction="inbound") is False

View file

@ -14,6 +14,7 @@ class BackendClient:
captured_at: datetime,
discord_message_id: str | None,
sender_id: str | None = None,
direction: str = "inbound",
) -> dict:
payload = {
"modality": modality,
@ -21,6 +22,7 @@ class BackendClient:
"captured_at": captured_at.isoformat(),
"discord_message_id": discord_message_id,
"sender_id": sender_id,
"direction": direction,
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{self._base_url}/items", json=payload, timeout=10)

View file

@ -1,6 +1,6 @@
import discord
from discord.ext import commands
from chorus_bot.modality_map import modality_for_channel
from chorus_bot.modality_map import modality_for_channel, direction_for_modality
from chorus_bot.ping import build_ping_embed
@ -32,6 +32,7 @@ class ManualShareCog(commands.Cog):
snippet=snippet,
raw_content=raw_content,
captured_at=message.created_at,
direction=direction_for_modality(modality),
)
inbox = self.bot.get_channel(self.config.inbox_channel_id)

View file

@ -31,6 +31,7 @@ class ThreadIngestCog(commands.Cog):
captured_at=parsed["captured_at"],
discord_message_id=str(thread.id),
sender_id=parsed["sender_id"],
direction=parsed["direction"],
)
await thread.send(
"Logged in Chorus - open the app to fill in details and track this one."

View file

@ -3,8 +3,15 @@ CHANNEL_TO_MODALITY = {
"voice": "voice",
"fb-messenger": "fb_messenger",
"nd-messenger": "nd_messenger",
"leads-found": "lead_found",
}
OUTBOUND_MODALITIES = {"lead_found", "magpie_lead"}
def modality_for_channel(channel_name: str) -> str | None:
return CHANNEL_TO_MODALITY.get(channel_name)
def direction_for_modality(modality: str) -> str:
return "outbound" if modality in OUTBOUND_MODALITIES else "inbound"

View file

@ -9,6 +9,8 @@ MODALITY_LABELS = {
"voice": "Voice",
"fb_messenger": "FB Messenger",
"nd_messenger": "ND Messenger",
"lead_found": "Found Lead",
"magpie_lead": "Magpie Lead",
}
# Discord's API rejects embed field values of length 0 (min 1, max 1024 chars).
@ -34,7 +36,7 @@ def _safe_field_value(value: str) -> str:
def build_ping_embed(
*, modality: str, sender_id: str | None, snippet: str, raw_content: str,
captured_at: datetime,
captured_at: datetime, direction: str = "inbound",
) -> discord.Embed:
label = MODALITY_LABELS.get(modality, modality)
embed = discord.Embed(
@ -42,6 +44,7 @@ def build_ping_embed(
description=snippet,
)
embed.add_field(name="modality", value=_safe_field_value(modality), inline=True)
embed.add_field(name="direction", value=_safe_field_value(direction), inline=True)
embed.add_field(name="sender_id", value=_safe_field_value(sender_id or ""), inline=True)
embed.add_field(
name="captured_at", value=_safe_field_value(captured_at.isoformat()), inline=True,
@ -64,9 +67,12 @@ def parse_ping_embed(embed: discord.Embed) -> dict:
if raw_content == _EMPTY_FIELD_SENTINEL:
raw_content = ""
direction = values.get("direction") or "inbound"
return {
"modality": values["modality"],
"sender_id": sender_id,
"raw_content": raw_content,
"captured_at": datetime.fromisoformat(values["captured_at"]),
"direction": direction,
}

View file

@ -0,0 +1,51 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
import discord
from chorus_bot.cogs.manual_share import ManualShareCog
from chorus_bot.ping import parse_ping_embed
class _FakeConfig:
inbox_channel_id = 111
triage_role_id = 222
def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999):
message = MagicMock(spec=discord.Message)
message.author.bot = author_bot
message.channel.id = channel_id
message.channel.name = channel_name
message.content = content
message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0)
return message
@pytest.mark.asyncio
async def test_leads_found_channel_forwards_with_outbound_direction():
bot = MagicMock()
inbox = AsyncMock()
bot.get_channel.return_value = inbox
cog = ManualShareCog(bot, _FakeConfig())
message = _make_message(channel_name="leads-found", content="Neighbor posting about book giveaway")
await cog.on_message(message)
sent_embed = inbox.send.call_args.kwargs["embed"]
parsed = parse_ping_embed(sent_embed)
assert parsed["direction"] == "outbound"
assert parsed["modality"] == "lead_found"
@pytest.mark.asyncio
async def test_existing_modality_channel_still_forwards_with_inbound_direction():
bot = MagicMock()
inbox = AsyncMock()
bot.get_channel.return_value = inbox
cog = ManualShareCog(bot, _FakeConfig())
message = _make_message(channel_name="voice", content="call about donation")
await cog.on_message(message)
sent_embed = inbox.send.call_args.kwargs["embed"]
parsed = parse_ping_embed(sent_embed)
assert parsed["direction"] == "inbound"

View file

@ -15,3 +15,17 @@ def test_unknown_channel_returns_none():
def test_inbox_and_general_channels_are_not_mapped():
assert modality_map.modality_for_channel("inbox") is None
assert modality_map.modality_for_channel("general") is None
def test_leads_found_channel_maps_to_lead_found_modality():
assert modality_map.modality_for_channel("leads-found") == "lead_found"
def test_direction_for_modality_outbound_for_leads():
assert modality_map.direction_for_modality("lead_found") == "outbound"
assert modality_map.direction_for_modality("magpie_lead") == "outbound"
def test_direction_for_modality_inbound_for_everything_else():
assert modality_map.direction_for_modality("bh_email") == "inbound"
assert modality_map.direction_for_modality("voice") == "inbound"

View file

@ -1,4 +1,5 @@
from datetime import datetime, timezone
import discord
from chorus_bot import ping
@ -90,3 +91,36 @@ def test_build_ping_embed_truncates_long_raw_content():
raw_content_field = next(f for f in embed.fields if f.name == "raw_content")
assert len(raw_content_field.value) <= 1024
assert "truncated" in raw_content_field.value
def test_build_and_parse_round_trip_includes_direction():
captured_at = datetime(2026, 7, 13, 7, 0, 0, tzinfo=timezone.utc)
embed = ping.build_ping_embed(
modality="lead_found", sender_id=None, snippet="found post",
raw_content="Someone posted about donating books on Nextdoor",
captured_at=captured_at, direction="outbound",
)
parsed = ping.parse_ping_embed(embed)
assert parsed["direction"] == "outbound"
def test_build_ping_embed_direction_defaults_to_inbound():
embed = ping.build_ping_embed(
modality="bh_email", sender_id="jane@example.com", snippet="hi",
raw_content="hi", captured_at=datetime(2026, 7, 13, 7, 0, 0, tzinfo=timezone.utc),
)
parsed = ping.parse_ping_embed(embed)
assert parsed["direction"] == "inbound"
def test_parse_ping_embed_defaults_direction_when_field_missing():
"""Regression guard: embeds built before this change have no direction
field at all -- parsing one must not raise and must default to inbound."""
embed = discord.Embed(title="bh_email", description="hi")
embed.add_field(name="modality", value="bh_email", inline=True)
embed.add_field(name="sender_id", value="jane@example.com", inline=True)
embed.add_field(name="captured_at", value="2026-07-13T07:00:00+00:00", inline=True)
embed.add_field(name="raw_content", value="hi", inline=False)
parsed = ping.parse_ping_embed(embed)
assert parsed["direction"] == "inbound"

View file

@ -0,0 +1,37 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
import discord
from chorus_bot.cogs.thread_ingest import ThreadIngestCog
from chorus_bot.ping import build_ping_embed
class _FakeConfig:
inbox_channel_id = 111
@pytest.mark.asyncio
async def test_thread_create_passes_direction_to_backend_client():
bot = MagicMock()
backend_client = AsyncMock()
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
embed = build_ping_embed(
modality="lead_found", sender_id=None, snippet="found post",
raw_content="Someone posting about book donation",
captured_at=__import__("datetime").datetime(2026, 7, 13, 7, 0, 0,
tzinfo=__import__("datetime").timezone.utc),
direction="outbound",
)
parent_message = MagicMock()
parent_message.embeds = [embed]
thread = MagicMock(spec=discord.Thread)
thread.id = 555
thread.parent_id = 111
thread.parent.fetch_message = AsyncMock(return_value=parent_message)
thread.send = AsyncMock()
await cog.on_thread_create(thread)
backend_client.create_item.assert_awaited_once()
assert backend_client.create_item.call_args.kwargs["direction"] == "outbound"

View file

@ -13,6 +13,10 @@ const followUpDate = ref('')
const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark']
const OTHER_STAGES = ['new', 'done']
const OUTBOUND_STAGES = [
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
]
watch(() => props.item, (item) => {
if (!item) return
@ -24,6 +28,7 @@ watch(() => props.item, (item) => {
}, { immediate: true })
function stagesForType() {
if (props.item?.direction === 'outbound') return OUTBOUND_STAGES
if (type.value === 'donation') return DONATION_STAGES
if (type.value === 'other') return OTHER_STAGES
return ['new']

View file

@ -16,6 +16,7 @@ function makeItem(overrides = {}) {
stage: 'new',
notes: '',
follow_up_date: '',
direction: 'inbound',
...overrides,
}
}
@ -53,4 +54,28 @@ describe('ItemModal', () => {
expect(wrapper.findAll('select')[1].element.value).toBe('new')
})
it('offers outbound stages when item.direction is outbound, regardless of type', () => {
const wrapper = mount(ItemModal, {
props: { item: makeItem({ direction: 'outbound', type: 'donation', stage: 'new' }) },
})
const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value)
expect(options).toEqual([
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
])
})
it('does not reset an outbound item stage when type changes', async () => {
const wrapper = mount(ItemModal, {
props: { item: makeItem({ direction: 'outbound', type: 'donation', stage: 'outreach_sent' }) },
})
const stageSelect = wrapper.findAll('select')[1]
expect(stageSelect.element.value).toBe('outreach_sent')
const typeSelect = wrapper.findAll('select')[0]
await typeSelect.setValue('other')
expect(wrapper.findAll('select')[1].element.value).toBe('outreach_sent')
})
})