package devicehistory import ( "os" "path/filepath" "strings" "testing" "time" "github.com/yuanyuanxiang/SimpleRemoter/server/go/connection" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" ) func testDevice(id, name string) hub.DeviceInfo { return hub.DeviceInfo{ ID: id, Name: name, Group: "office", Version: "1.2.3", OS: "Windows 11", IP: "203.0.113.7", PeerIP: "198.51.100.9", Screen: "1:1920*1080", Online: true, } } func TestStoreRecordsLifecycleViaHub(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") s, err := Open(path, 1000, nil) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) h := hub.New() h.Subscribe(s) h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"}, &connection.Context{RemoteAddr: "198.51.100.9:5000"}) // The device is online, so it must not appear in the offline list. if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 { t.Fatalf("offline while online = %d, want 0", len(got)) } h.Unregister("dev-1") off := s.Offline(nil) if len(off) != 1 { t.Fatalf("offline entries = %d, want 1", len(off)) } d := off[0] if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" { t.Fatalf("offline device = %+v", d) } if d.Online { t.Fatal("offline entry marked online") } if d.LastSeen == 0 { t.Fatal("last_seen not set") } } func TestStorePersistsAndPreservesFirstSeen(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") s, err := Open(path, 1000, nil) if err != nil { t.Fatalf("Open: %v", err) } first := time.Unix(1_700_000_000, 0) s.upsertAt(testDevice("dev-1", "old-name"), first) // A later login refreshes display fields and last_seen but keeps first_seen. s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour)) if err := s.Flush(); err != nil { t.Fatalf("Flush: %v", err) } _ = s.Close() reopened, err := Open(path, 1000, nil) if err != nil { t.Fatalf("reopen: %v", err) } t.Cleanup(func() { _ = reopened.Close() }) reopened.mu.Lock() rec := reopened.devices["dev-1"] reopened.mu.Unlock() if rec == nil { t.Fatal("record not persisted") } if rec.Name != "new-name" { t.Fatalf("name = %q, want new-name", rec.Name) } if rec.FirstSeen != first.Unix() { t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix()) } if rec.LastSeen != first.Add(time.Hour).Unix() { t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix()) } } func TestStorePrunesOldest(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") s, err := Open(path, 2, nil) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) base := time.Unix(1_700_000_000, 0) s.upsertAt(testDevice("a", "A"), base) s.upsertAt(testDevice("b", "B"), base.Add(time.Minute)) s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute)) s.mu.Lock() _, hasA := s.devices["a"] _, hasC := s.devices["c"] n := len(s.devices) s.mu.Unlock() if n != 2 || hasA || !hasC { t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC) } } func TestPruneEvictsOfflineBeforeOnline(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") s, err := Open(path, 2, nil) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) base := time.Unix(1_700_000_000, 0) s.upsertAt(testDevice("online-old", "Old"), base) s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute)) s.offlineAt("offline-new", base.Add(2*time.Minute)) // This upsert exceeds the cap; the offline record must be evicted before // the still-connected device, even though its last_seen is newer. s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute)) s.mu.Lock() _, hasOld := s.devices["online-old"] _, hasGone := s.devices["offline-new"] _, hasNew := s.devices["online-2"] n := len(s.devices) s.mu.Unlock() if n != 2 || !hasOld || hasGone || !hasNew { t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew) } } func TestStoreOfflineSortedNewestFirst(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") s, err := Open(path, 1000, nil) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) base := time.Unix(1_700_000_000, 0) s.upsertAt(testDevice("old", "Old"), base) s.upsertAt(testDevice("new", "New"), base.Add(time.Hour)) off := s.Offline(nil) if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" { t.Fatalf("offline order = %+v, want new then old", off) } } func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { t.Fatal(err) } s, err := Open(path, 1000, nil) if err == nil { t.Fatal("Open corrupt file: want error, got nil") } if len(s.devices) != 0 { t.Fatalf("corrupt load produced %d records, want 0", len(s.devices)) } t.Cleanup(func() { _ = s.Close() }) s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0)) if err := s.Flush(); err != nil { t.Fatalf("Flush after corrupt load: %v", err) } reopened, err := Open(path, 1000, nil) if err != nil { t.Fatalf("reopen: %v", err) } t.Cleanup(func() { _ = reopened.Close() }) if len(reopened.devices) != 1 { t.Fatalf("records after recovery = %d, want 1", len(reopened.devices)) } } func TestStoreRemovePersistsDeletion(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") s, err := Open(path, 1000, nil) if err != nil { t.Fatalf("Open: %v", err) } base := time.Unix(1_700_000_000, 0) s.upsertAt(testDevice("gone", "Gone"), base) s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute)) if !s.Remove("gone") { t.Fatal("Remove existing record = false, want true") } if s.Remove("gone") { t.Fatal("Remove missing record = true, want false") } if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" { t.Fatalf("offline after remove = %+v, want only kept", got) } if err := s.Flush(); err != nil { t.Fatalf("Flush: %v", err) } _ = s.Close() reopened, err := Open(path, 1000, nil) if err != nil { t.Fatalf("reopen: %v", err) } t.Cleanup(func() { _ = reopened.Close() }) if _, ok := reopened.devices["gone"]; ok { t.Fatal("removed record came back after reload") } if _, ok := reopened.devices["kept"]; !ok { t.Fatal("kept record missing after reload") } } func TestFlushLeavesNoTempFiles(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "devices.json") s, err := Open(path, 1000, nil) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0)) if err := s.Flush(); err != nil { t.Fatalf("Flush: %v", err) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, e := range entries { if strings.HasSuffix(e.Name(), ".tmp") { t.Fatalf("leftover temp file: %s", e.Name()) } } }