// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. package notify import ( "encoding/json" "os" "path/filepath" "testing" "time" "github.com/godbus/dbus/v5" ) // The integration tests need a session bus this process can own the name on. // Run them with dbus-run-session -- go test ./internal/notify func busOrSkip(t *testing.T) *dbus.Conn { t.Helper() conn, err := dbus.SessionBus() if err != nil { t.Skipf("no session bus: %v", err) } reply, err := conn.RequestName(Name, dbus.NameFlagDoNotQueue) if err != nil { t.Skipf("cannot request the name: %v", err) } // AlreadyOwner happens on the second bus test in one process, because // dbus.SessionBus is a shared connection. That is fine: the name is ours. if reply != dbus.RequestNameReplyPrimaryOwner && reply != dbus.RequestNameReplyAlreadyOwner { t.Skipf("cannot own the name, run under dbus-run-session (reply %v)", reply) } return conn } func TestIdentityAndCapabilities(t *testing.T) { s := &Service{} name, vendor, _, spec, err := s.GetServerInformation() if err != nil { t.Fatalf("GetServerInformation: %v", err) } if name != "danix" || vendor != "danix" || spec != "1.2" { t.Errorf("identity = %q/%q spec %q, want danix/danix 1.2", name, vendor, spec) } caps, err := s.GetCapabilities() if err != nil { t.Fatalf("GetCapabilities: %v", err) } for _, want := range []string{"actions", "body-markup", "icon-static", "persistence"} { found := false for _, c := range caps { if c == want { found = true } } if !found { t.Errorf("missing capability %q in %v", want, caps) } } } func TestNotifyReturnsAnIDAndPublishes(t *testing.T) { conn := busOrSkip(t) dir := t.TempDir() svc := NewService(conn, dir) if err := svc.Start(); err != nil { t.Fatalf("Start: %v", err) } obj := conn.Object(Name, dbus.ObjectPath(objPath)) call := obj.Call(iface+".Notify", 0, "app", uint32(0), "icon", "summary", "body", []string{"default", "open"}, map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}, int32(0)) if call.Err != nil { t.Fatalf("Notify: %v", call.Err) } var id uint32 if err := call.Store(&id); err != nil { t.Fatalf("Notify reply: %v", err) } if id == 0 { t.Fatal("Notify returned id 0") } data, err := os.ReadFile(filepath.Join(dir, "queue.json")) if err != nil { t.Fatalf("read queue: %v", err) } var live []Popup if err := json.Unmarshal(data, &live); err != nil { t.Fatalf("queue not JSON: %v", err) } if len(live) != 1 || live[0].Summary != "summary" { t.Fatalf("queue = %+v, want one summary", live) } if live[0].Expires == 0 { t.Error("expires is zero, want a normal timeout") } } func TestCloseNotificationEmitsReasonThree(t *testing.T) { conn := busOrSkip(t) dir := t.TempDir() svc := NewService(conn, dir) if err := svc.Start(); err != nil { t.Fatalf("Start: %v", err) } signals := make(chan *dbus.Signal, 4) conn.Signal(signals) if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(objPath))); err != nil { t.Fatalf("AddMatchSignal: %v", err) } obj := conn.Object(Name, dbus.ObjectPath(objPath)) call := obj.Call(iface+".Notify", 0, "app", uint32(0), "", "s", "b", []string{}, map[string]dbus.Variant{}, int32(-1)) var id uint32 if err := call.Store(&id); err != nil { t.Fatalf("Notify reply: %v", err) } if err := obj.Call(iface+".CloseNotification", 0, id).Err; err != nil { t.Fatalf("CloseNotification: %v", err) } select { case sig := <-signals: if sig.Name != iface+".NotificationClosed" { t.Fatalf("signal %q, want NotificationClosed", sig.Name) } if sig.Body[0].(uint32) != id || sig.Body[1].(uint32) != 3 { t.Errorf("signal body = %v, want id %d reason 3", sig.Body, id) } case <-time.After(2 * time.Second): t.Fatal("no NotificationClosed signal") } } // A natural expiry must drop the timer entry, or the map grows by one timer per // id for the whole session. No bus is needed: the store's emit is a no-op here, // so the test exercises arm's closure directly. func TestNaturalExpiryDeletesTheTimerEntry(t *testing.T) { s := &Service{dir: t.TempDir(), timers: map[uint32]*time.Timer{}} s.store = NewStore(func(id, reason uint32) {}, func(live, history []Popup) {}, nil) id, err := s.Notify("app", 0, "", "s", "b", nil, nil, 50) if err != nil { t.Fatalf("Notify: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { s.mu.Lock() _, present := s.timers[id] s.mu.Unlock() if !present { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("timer entry for id %d still present after expiry", id) }