package messaging import ( "context" "encoding/json" "errors" "strings" "sync" "testing" "time" "nearle/models" mqtt "github.com/eclipse/paho.mqtt.golang" ) // These drive the real handlers through paho's own interfaces, so what is under // test is the code that runs in production rather than a parallel // reimplementation of it. // // No embedded broker: the infrastructure audit established that the broker is // Mosquitto 2.1.2 and that it works. What was never established is whether // *this* code acks the right terminal, and refuses to ack when the ingest // failed — which is where a bug would cost a shop its takings. // fakePosService lets a test decide what the ingest did. type fakePosService struct { ack *models.PosAck err error batches []models.PosOrderBatch custBatch []models.PosCustomerBatch heartbeats []models.PosHealth healthErr error } func (f *fakePosService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) { f.batches = append(f.batches, batch) return f.ack, f.err } func (f *fakePosService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) { f.custBatch = append(f.custBatch, batch) return f.ack, f.err } func (f *fakePosService) Catalogue(string, string, int, int) (*models.PosCatalogueResponse, error) { return nil, nil } func (f *fakePosService) RecordHealth(_ context.Context, health models.PosHealth) error { f.heartbeats = append(f.heartbeats, health) return f.healthErr } func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]string, error) { return nil, nil } func (f *fakePosService) Sales(models.PosSalesFilter) (*models.PosSalesPage, error) { return nil, nil } func (f *fakePosService) SaleDetail(int, string) (*models.PosOrders, error) { return nil, nil } func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) { return nil, nil } func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) { return nil, nil } // ---------------------------------------------------------------- paho fakes type published struct { topic string qos byte retained bool payload []byte } // fakeClient records what was published and nothing else. type fakeClient struct { mu sync.Mutex sent []published } func (c *fakeClient) Publish(topic string, qos byte, retained bool, payload any) mqtt.Token { c.mu.Lock() defer c.mu.Unlock() body, _ := payload.([]byte) c.sent = append(c.sent, published{topic: topic, qos: qos, retained: retained, payload: body}) return doneToken{} } func (c *fakeClient) publishes() []published { c.mu.Lock() defer c.mu.Unlock() return append([]published(nil), c.sent...) } func (c *fakeClient) IsConnected() bool { return true } func (c *fakeClient) IsConnectionOpen() bool { return true } func (c *fakeClient) Connect() mqtt.Token { return doneToken{} } func (c *fakeClient) Disconnect(uint) {} func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token { return doneToken{} } func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token { return doneToken{} } func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} } func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {} func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} } type doneToken struct{} func (doneToken) Wait() bool { return true } func (doneToken) WaitTimeout(time.Duration) bool { return true } func (doneToken) Done() <-chan struct{} { ch := make(chan struct{}) close(ch) return ch } func (doneToken) Error() error { return nil } type fakeMessage struct { topic string payload []byte } func (m fakeMessage) Duplicate() bool { return false } func (m fakeMessage) Qos() byte { return 1 } func (m fakeMessage) Retained() bool { return false } func (m fakeMessage) Topic() string { return m.topic } func (m fakeMessage) MessageID() uint16 { return 1 } func (m fakeMessage) Payload() []byte { return m.payload } func (m fakeMessage) Ack() {} func consumerFor(svc *fakePosService) (*PosMqttConsumer, *fakeClient) { client := &fakeClient{} return &PosMqttConsumer{client: client, svc: svc}, client } func orderBatch(batchID string, ids ...string) []byte { orders := make([]models.PosOrder, 0, len(ids)) for _, id := range ids { orders = append(orders, models.PosOrder{Id: id, Invoicenumber: "INV-" + id}) } body, _ := json.Marshal(models.PosOrderBatch{Schema: 1, Batchid: batchID, Orders: orders}) return body } // ---------------------------------------------------------------------- tests func TestAckGoesBackToTheTerminalThatSent(t *testing.T) { ack := models.NewPosAck("batch-1") ack.Accept("order-a") c, client := consumerFor(&fakePosService{ack: ack}) c.handleOrders(nil, fakeMessage{ topic: "nearle/pos/12/T4A9/order", payload: orderBatch("batch-1", "order-a"), }) sent := client.publishes() if len(sent) != 1 { t.Fatalf("published %d messages, want exactly 1", len(sent)) } // Addressed to the till that sent it. A store-wide ack would tell every // other counter that bills they never sent had landed. if sent[0].topic != "nearle/pos/12/T4A9/ack" { t.Errorf("ack topic = %q, want nearle/pos/12/T4A9/ack", sent[0].topic) } if sent[0].qos != 1 { t.Errorf("ack qos = %d, want 1", sent[0].qos) } if sent[0].retained { t.Error("the ack was retained; a stale ack replayed to a new session would retire bills that were never sent") } var got models.PosAck if err := json.Unmarshal(sent[0].payload, &got); err != nil { t.Fatalf("decode ack: %v", err) } if got.Batchid != "batch-1" { t.Errorf("batch_id = %q, want batch-1", got.Batchid) } if len(got.Accepted) != 1 || got.Accepted[0] != "order-a" { t.Errorf("accepted = %v, want [order-a]", got.Accepted) } } func TestAFailedIngestIsNotAcked(t *testing.T) { // The single most important behaviour here. An ack the ingest did not earn // tells a terminal to delete a bill that was never banked. c, client := consumerFor(&fakePosService{err: errors.New("database is having a bad minute")}) c.handleOrders(nil, fakeMessage{ topic: "nearle/pos/12/T4A9/order", payload: orderBatch("batch-2", "order-a"), }) if sent := client.publishes(); len(sent) != 0 { t.Fatalf("a batch that failed to commit was acknowledged: %s", sent[0].payload) } } func TestStoreAndTerminalComeFromTheTopic(t *testing.T) { // The body is authoritative for nothing about identity. A till that could // name a store in its payload could post sales into another shop's books. svc := &fakePosService{ack: models.NewPosAck("batch-3")} c, _ := consumerFor(svc) c.handleOrders(nil, fakeMessage{ topic: "nearle/pos/44/T0001/order", payload: orderBatch("batch-3", "order-x"), }) if len(svc.batches) != 1 { t.Fatalf("the batch never reached the ingest") } if got := svc.batches[0].Storeid; got != "44" { t.Errorf("store_id = %q, want 44 (from the topic)", got) } if got := svc.batches[0].Terminalid; got != "T0001" { t.Errorf("terminal_id = %q, want T0001", got) } } func TestABodyCannotOverrideTheTopicIdentity(t *testing.T) { // A till claiming to be somewhere else must not be believed. svc := &fakePosService{ack: models.NewPosAck("batch-4")} c, client := consumerFor(svc) body, _ := json.Marshal(models.PosOrderBatch{ Schema: 1, Batchid: "batch-4", Storeid: "99", // a shop this till has no claim on Orders: []models.PosOrder{{Id: "order-a"}}, }) c.handleOrders(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: body}) // The ingest still resolves the store it was *told*, which is a known gap — // but the ack must go back to the real terminal, so a forged store id // cannot redirect another till's acknowledgements. sent := client.publishes() if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" { t.Fatalf("ack went to %v, want nearle/pos/12/T4A9/ack", sent) } } func TestAMalformedBatchIsDroppedWithoutAcking(t *testing.T) { // Nothing to key an ack on, and nothing committed. Silence is correct: the // till times out and re-sends. svc := &fakePosService{ack: models.NewPosAck("x")} c, client := consumerFor(svc) c.handleOrders(nil, fakeMessage{ topic: "nearle/pos/12/T4A9/order", payload: []byte("not json at all"), }) if len(svc.batches) != 0 { t.Error("an unreadable batch reached the ingest") } if sent := client.publishes(); len(sent) != 0 { t.Errorf("an unreadable batch was acknowledged: %s", sent[0].payload) } } func TestRegistrationsAckOnTheSameTopic(t *testing.T) { ack := models.NewPosAck("cust-1") ack.Accept("customer-a") c, client := consumerFor(&fakePosService{ack: ack}) body, _ := json.Marshal(models.PosCustomerBatch{ Schema: 1, Batchid: "cust-1", Customers: []models.PosCustomer{{Id: "customer-a", Mobile: "9840012345", Name: "Meena"}}, }) c.handleCustomers(nil, fakeMessage{topic: "nearle/pos/12/T4A9/customer", payload: body}) sent := client.publishes() if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" { t.Fatalf("registration ack went to %v", sent) } } func TestAHeartbeatIsRecordedAndNeverAcked(t *testing.T) { // Presence is fire-and-forget. A till waiting on an ack for its heartbeat // would be a till that a busy dashboard can block. svc := &fakePosService{} c, client := consumerFor(svc) body, _ := json.Marshal(models.PosHealth{Status: "online", Pendingbills: 4, Todaybills: 37}) c.handleHealth(nil, fakeMessage{topic: "nearle/pos/12/T4A9/health", payload: body}) if len(svc.heartbeats) != 1 { t.Fatalf("the heartbeat never reached the presence store") } got := svc.heartbeats[0] if got.Terminalid != "T4A9" || got.Locationid != "12" { t.Errorf("identity = %s/%s, want 12/T4A9 (from the topic)", got.Locationid, got.Terminalid) } if got.Pendingbills != 4 { t.Errorf("pending_bills = %d, want 4", got.Pendingbills) } if sent := client.publishes(); len(sent) != 0 { t.Error("a heartbeat was acknowledged; presence must be fire-and-forget") } } func TestALastWillIsRecordedAsOffline(t *testing.T) { // The broker publishes this on the till's behalf when it loses power. It // carries nothing but a status, and that is the point — it is the only way // to tell "closed for the night" from "unplugged". svc := &fakePosService{} c, _ := consumerFor(svc) c.handleHealth(nil, fakeMessage{ topic: "nearle/pos/12/T4A9/health", payload: []byte(`{"status":"offline"}`), }) if len(svc.heartbeats) != 1 { t.Fatal("the will never reached the presence store") } if got := svc.heartbeats[0].Status; got != "offline" { t.Errorf("status = %q, want offline — a will must not be defaulted to online", got) } } func TestAFailedPresenceWriteDoesNotStopTheTill(t *testing.T) { // Redis being unreachable must cost the board, never a sale. svc := &fakePosService{healthErr: errors.New("redis is down")} c, client := consumerFor(svc) c.handleHealth(nil, fakeMessage{ topic: "nearle/pos/12/T4A9/health", payload: []byte(`{"status":"online"}`), }) if sent := client.publishes(); len(sent) != 0 { t.Error("a failed heartbeat produced a message back to the till") } } func TestAnEmptyAckSerialisesAsAListNotNull(t *testing.T) { // A terminal reading `null` for accepted treats the whole batch as // unconfirmed and sends it again for ever. body, err := json.Marshal(models.NewPosAck("batch-5")) if err != nil { t.Fatalf("marshal: %v", err) } if want := `"accepted":[]`; !strings.Contains(string(body), want) { t.Errorf("ack serialised as %s, want it to contain %s", body, want) } } func TestTopicIdentityRejectsShortTopics(t *testing.T) { // A topic that names no terminal must yield nothing rather than a // plausible-looking wrong answer that sends an ack to the wrong place. for _, topic := range []string{"nearle/pos/order", "pos/12/T4A9/order", "", "nearle"} { store, terminal := topicIdentity(topic) if store != "" || terminal != "" { t.Errorf("topicIdentity(%q) = %q/%q, want empty", topic, store, terminal) } } store, terminal := topicIdentity("nearle/pos/12/T4A9/order") if store != "12" || terminal != "T4A9" { t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal) } } // MQTT has no queue groups: every subscriber gets every message. Three replicas // all consuming would commit the same bill three times and publish three acks — // harmless, because the ingest is idempotent, but three times the work. func TestOnlyTheFirstStatefulSetReplicaConsumes(t *testing.T) { cases := []struct { name string hostname string override string want bool }{ {"statefulset ordinal 0", "fiesta-0", "", true}, {"statefulset ordinal 1", "fiesta-1", "", false}, {"statefulset ordinal 2", "fiesta-2", "", false}, {"double-digit ordinal", "fiesta-10", "", false}, // A Deployment pod has a random suffix, not an ordinal. Refusing to // consume there would be a far more confusing failure than consuming. {"deployment pod", "fiesta-7d4f9c8b6d-x2k9p", "", true}, {"bare container", "a1b2c3d4e5f6", "", true}, {"no hostname", "", "", true}, // The override settles it outright either way. {"forced on", "fiesta-2", "always", true}, {"forced off", "fiesta-0", "never", false}, {"forced on via true", "fiesta-5", "true", true}, {"forced off via false", "fiesta-0", "false", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Setenv("HOSTNAME", c.hostname) t.Setenv("POS_MQTT_CONSUMER", c.override) if got := posConsumerElected(); got != c.want { t.Errorf("posConsumerElected() = %v, want %v (hostname %q, override %q)", got, c.want, c.hostname, c.override) } }) } }