merge: integrate book-donation-chorus-search into freeze branch for combined testing
This commit is contained in:
commit
b260ffcc46
18 changed files with 277 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
51
bot/tests/test_manual_share.py
Normal file
51
bot/tests/test_manual_share.py
Normal 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
37
bot/tests/test_thread_ingest.py
Normal file
37
bot/tests/test_thread_ingest.py
Normal 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"
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue