Items are now private to their owner. Add items.user_id (migrated in place, existing items backfilled to the first admin) and scope every item, results, and dashboard view to the signed-in user via owner-scoped queries plus an ownedItem 404 guard. Admins get strict isolation too; elevation stays limited to settings and user management. Alert routing follows ownership: deal emails go only to the item owner and the weekly digest is built per-recipient from their own items. The scheduler still polls every active item; the Apify/eBay budget stays a shared pool visible to all. Add TestItemsArePrivatePerUser and seed owners in db tests.
160 lines
4.0 KiB
Go
160 lines
4.0 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"veola/internal/crypto"
|
|
"veola/internal/models"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
conn, err := Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { conn.Close() })
|
|
key, _ := crypto.DeriveKey([]byte("0123456789abcdef0123456789abcdef-aaa"))
|
|
return NewStore(conn, key)
|
|
}
|
|
|
|
// seedUser creates an owner so item rows satisfy the items.user_id foreign key.
|
|
func seedUser(t *testing.T, s *Store) int64 {
|
|
t.Helper()
|
|
id, err := s.CreateUser(context.Background(), "tester", "!x", models.RoleAdmin)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestDedupByItemAndURL(t *testing.T) {
|
|
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
|
t.Skip()
|
|
}
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
uid := seedUser(t, s)
|
|
|
|
id, err := s.CreateItem(ctx, &models.Item{
|
|
UserID: uid,
|
|
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
|
PollIntervalMinutes: 60, NtfyPriority: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := &models.Result{
|
|
ItemID: id,
|
|
Title: "TwinBee Famicom",
|
|
Currency: "USD",
|
|
URL: "https://example.com/listing/1",
|
|
}
|
|
if _, err := s.InsertResult(ctx, r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
exists, err := s.ResultExists(ctx, id, "https://example.com/listing/1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists {
|
|
t.Error("expected result to be detected as duplicate")
|
|
}
|
|
|
|
missing, _ := s.ResultExists(ctx, id, "https://example.com/listing/2")
|
|
if missing {
|
|
t.Error("expected unknown URL to not be flagged as duplicate")
|
|
}
|
|
|
|
// Different item, same URL should not collide.
|
|
id2, _ := s.CreateItem(ctx, &models.Item{UserID: uid, Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"})
|
|
other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1")
|
|
if other {
|
|
t.Error("dedup should be scoped to item_id")
|
|
}
|
|
|
|
// Empty URL should not collide.
|
|
emptyExists, _ := s.ResultExists(ctx, id, "")
|
|
if emptyExists {
|
|
t.Error("empty URL should never be flagged as duplicate")
|
|
}
|
|
}
|
|
|
|
func TestDashboardMoneySavedWindow(t *testing.T) {
|
|
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
|
t.Skip()
|
|
}
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
uid := seedUser(t, s)
|
|
|
|
bp := 100.0
|
|
id, err := s.CreateItem(ctx, &models.Item{
|
|
UserID: uid,
|
|
Name: "Windowed", NtfyTopic: "veola", Active: true,
|
|
PollIntervalMinutes: 60, NtfyPriority: "default",
|
|
BestPrice: &bp,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
now := time.Now()
|
|
// A stale point well outside the 30-day window at a much higher price. If
|
|
// the average were computed over all history this would inflate "saved".
|
|
if err := s.InsertPricePoint(ctx, &models.PricePoint{
|
|
ItemID: id, Price: 500, PolledAt: now.AddDate(0, 0, -60),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A recent point inside the window. Best price (100) is below it, so the
|
|
// only legitimate saving is 120 - 100 = 20.
|
|
if err := s.InsertPricePoint(ctx, &models.PricePoint{
|
|
ItemID: id, Price: 120, PolledAt: now.AddDate(0, 0, -5),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stats, err := s.GetDashboardStats(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stats.SavedItemCount != 1 {
|
|
t.Fatalf("expected 1 saved item, got %d", stats.SavedItemCount)
|
|
}
|
|
if got := stats.MoneySaved; got < 19.99 || got > 20.01 {
|
|
t.Errorf("expected money saved ~20 (recent avg only), got %.2f", got)
|
|
}
|
|
}
|
|
|
|
func TestCJKRoundTrip(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
uid := seedUser(t, s)
|
|
id, err := s.CreateItem(ctx, &models.Item{
|
|
UserID: uid,
|
|
Name: "ツインビー",
|
|
SearchQuery: "ツインビー グラディウス パロディウス",
|
|
NtfyTopic: "veola",
|
|
Active: true,
|
|
PollIntervalMinutes: 60, NtfyPriority: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.GetItem(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Name != "ツインビー" || got.SearchQuery != "ツインビー グラディウス パロディウス" {
|
|
t.Errorf("CJK round-trip failed: name=%q query=%q", got.Name, got.SearchQuery)
|
|
}
|
|
}
|