Files
BountyBoard/internal/domain/status_test.go
T
etalon 4e01b64bbd feat: ticketing sync workers with idempotent import and orphan flagging (phase 6)
- task domain model + exhaustive §4.4 status machine tests (single source of truth)
- per-customer polling workers reconciled every 30s from the customers
  collection; panic-safe runs; manual sync-now trigger; status provider
  for the admin panel
- §5.3 upsert keyed on (system, key, customerId): refreshes content while
  status=imported, records upstream_changed timeline + notification after
- attachments cached into GridFS via connector-authorized downloads
- orphaned tickets flagged with timeline + consultant notification, never
  deleted; idempotent across polls
- consultant identity from users.extra.ticketingIdentities per system
- notifications store (insert/list/unread-count/mark-read)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:03:13 +02:00

99 lines
3.3 KiB
Go

package domain
import "testing"
// TestTransitionMatrixExhaustive enumerates every (from, to) pair and pins
// the §4.4 machine exactly: the allowed set below is the complete truth.
func TestTransitionMatrixExhaustive(t *testing.T) {
allowed := map[TaskStatus]map[TaskStatus]bool{
StatusImported: {StatusAtomizing: true, StatusArchived: true},
StatusAtomizing: {StatusAtomized: true, StatusImported: true, StatusArchived: true},
StatusAtomized: {StatusPublished: true, StatusAtomizing: true, StatusArchived: true},
StatusPublished: {StatusClaimRequested: true, StatusAssigned: true, StatusArchived: true},
StatusClaimRequested: {StatusAssigned: true, StatusPublished: true, StatusArchived: true},
StatusAssigned: {StatusInProgress: true, StatusInReview: true, StatusPublished: true, StatusArchived: true},
StatusInProgress: {StatusInReview: true, StatusPublished: true, StatusArchived: true},
StatusInReview: {StatusApproved: true, StatusChangesRequested: true, StatusAssigned: true, StatusArchived: true},
StatusChangesRequested: {StatusInProgress: true, StatusArchived: true},
StatusApproved: {StatusArchived: true},
StatusArchived: {},
}
for _, from := range AllStatuses {
for _, to := range AllStatuses {
want := allowed[from][to]
if got := CanTransition(from, to); got != want {
t.Errorf("CanTransition(%s, %s) = %v, want %v", from, to, got, want)
}
err := Transition(from, to)
if want && err != nil {
t.Errorf("Transition(%s, %s) unexpected error %v", from, to, err)
}
if !want && err == nil {
t.Errorf("Transition(%s, %s) should error", from, to)
}
}
}
}
func TestTransitionRejectsUnknownStatuses(t *testing.T) {
if CanTransition("bogus", StatusArchived) {
t.Error("unknown from-status must be rejected")
}
if CanTransition(StatusImported, "bogus") {
t.Error("unknown to-status must be rejected")
}
if CanTransition(StatusImported, StatusImported) {
t.Error("self-transition must be rejected")
}
}
func TestStatusValid(t *testing.T) {
for _, s := range AllStatuses {
if !s.Valid() {
t.Errorf("%s should be valid", s)
}
}
if TaskStatus("nope").Valid() {
t.Error("nope should be invalid")
}
}
func TestComputeBounty(t *testing.T) {
tests := []struct {
coeff, budget, want float64
}{
{0.25, 1000, 250},
{0.2, 1000, 200},
{1.0 / 3.0, 1000, 333.33},
{0.333, 100, 33.3},
{0, 1000, 0},
{1, 0, 0},
{0.01, 99.99, 1.0}, // 0.9999 rounds to 1.00
{0.005, 100, 0.5},
{2.0, 500, 1000}, // extension coefficients may exceed 1 (§5.1)
}
for _, tt := range tests {
if got := ComputeBounty(tt.coeff, tt.budget); got != tt.want {
t.Errorf("ComputeBounty(%v, %v) = %v, want %v", tt.coeff, tt.budget, got, tt.want)
}
}
}
func TestTaskHelpers(t *testing.T) {
task := &Task{
Assignee: &Assignee{Kind: "human", UserID: "dev1"},
ClaimRequests: []ClaimRequest{{DeveloperID: "dev2"}},
}
if !task.IsAssignedTo("dev1") || task.IsAssignedTo("dev2") {
t.Error("IsAssignedTo wrong")
}
if !task.HasClaimFrom("dev2") || task.HasClaimFrom("dev1") {
t.Error("HasClaimFrom wrong")
}
aiTask := &Task{Assignee: &Assignee{Kind: "ai", JobID: "wp_1"}}
if aiTask.IsAssignedTo("dev1") {
t.Error("ai assignee must not match human checks")
}
}