1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
package main

import (
	"context"
	"testing"
)

// newTestApp builds an app wired to the development database, with no network
// clients: the query tests must not touch Redacted or Transmission.
func newTestApp(t *testing.T) *app {
	t.Helper()
	ctx := context.Background()
	pool, err := connectDB(ctx, devDSN(t))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)
	return &app{pool: pool}
}

// TestGetBestTorrentsRunsAgainstRealData executes the ported query against the
// restored production database.
//
// The query is the most intricate SQL in the project (nested CTEs, DISTINCT ON,
// array overlap against a GIN index) and was translated placeholder-by-
// placeholder from the Haskell version, so the thing worth checking is that the
// translation still produces sensible rows on real data.
func TestGetBestTorrentsRunsAgainstRealData(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	limit := 50
	rows, err := a.getBestTorrents(ctx, bestTorrentsFilter{
		LimitResults: &limit,
		Ordering:     bySeedingWeight,
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) == 0 {
		t.Fatal("expected some torrents from the development database")
	}
	t.Logf("got %d rows", len(rows))

	// bySeedingWeight must actually be sorted, since the whole point of the
	// query is to surface the best torrent first.
	for i := 1; i < len(rows); i++ {
		if rows[i-1].SeedingWeight < rows[i].SeedingWeight {
			t.Errorf("rows are not ordered by seeding weight: %d < %d at %d",
				rows[i-1].SeedingWeight, rows[i].SeedingWeight, i)
			break
		}
	}

	// Every row must be renderable: these fields are all displayed.
	for _, td := range rows {
		if td.TorrentID == 0 || td.GroupID == 0 {
			t.Errorf("row with zero ids: %+v", td)
		}
		if td.GroupName == "" {
			t.Errorf("torrent %d has no group name", td.TorrentID)
		}
		if td.ReleaseType.StringKey == "" {
			t.Errorf("torrent %d has no release type", td.TorrentID)
		}
	}

	// DISTINCT ON (torrent_group) means at most one torrent per group.
	seen := map[int]bool{}
	for _, td := range rows {
		if seen[td.GroupID] {
			t.Errorf("group %d appears more than once", td.GroupID)
		}
		seen[td.GroupID] = true
	}
}

// TestGetBestTorrentsLimit checks that the limit is applied, because it is
// passed as a nullable parameter (LIMIT NULL means "no limit" in PostgreSQL,
// which is what makes the nil case work).
func TestGetBestTorrentsLimit(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	limit := 5
	limited, err := a.getBestTorrents(ctx, bestTorrentsFilter{LimitResults: &limit})
	if err != nil {
		t.Fatal(err)
	}
	if len(limited) != 5 {
		t.Errorf("got %d rows, want 5", len(limited))
	}

	unlimited, err := a.getBestTorrents(ctx, bestTorrentsFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if len(unlimited) <= 5 {
		t.Errorf("unlimited query returned only %d rows", len(unlimited))
	}
}

// TestGetBestTorrentsArtistFilter checks the artist filter, which is expressed
// as "$2::bool OR ($3::int = any (artist_ids))" — a disabled-flag plus value
// pair that is easy to get backwards.
func TestGetBestTorrentsArtistFilter(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	// Pick an artist that actually has torrents.
	var artistID int
	err := a.pool.QueryRow(ctx, `
		SELECT unnest(artist_ids) AS artist_id
		FROM redacted.torrents_json
		WHERE array_length(artist_ids, 1) > 0
		LIMIT 1`).Scan(&artistID)
	if err != nil {
		t.Fatal(err)
	}

	rows, err := a.getBestTorrents(ctx, bestTorrentsFilter{OnlyArtistID: &artistID})
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) == 0 {
		t.Fatalf("no torrents for artist %d, expected at least one", artistID)
	}
	// Every returned torrent must actually involve that artist.
	for _, td := range rows {
		var belongs bool
		if err := a.pool.QueryRow(ctx,
			`SELECT $1::int = ANY (artist_ids) FROM redacted.torrents_json WHERE torrent_id = $2`,
			artistID, td.TorrentID).Scan(&belongs); err != nil {
			t.Fatal(err)
		}
		if !belongs {
			t.Errorf("torrent %d does not belong to artist %d", td.TorrentID, artistID)
		}
	}
}

// TestGetBestTorrentsTorrentIDFilter checks the "only these torrents" filter
// used to display search results.
func TestGetBestTorrentsTorrentIDFilter(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	rows, err := a.pool.Query(ctx, `SELECT torrent_id FROM redacted.torrents_json LIMIT 10`)
	if err != nil {
		t.Fatal(err)
	}
	var ids []int
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			t.Fatal(err)
		}
		ids = append(ids, id)
	}
	rows.Close()

	got, err := a.getBestTorrents(ctx, bestTorrentsFilter{OnlyTheseTorrents: ids})
	if err != nil {
		t.Fatal(err)
	}
	if len(got) == 0 {
		t.Fatal("expected at least one row")
	}
	allowed := map[int]bool{}
	for _, id := range ids {
		allowed[id] = true
	}
	for _, td := range got {
		if !allowed[td.TorrentID] {
			t.Errorf("torrent %d was not in the requested set", td.TorrentID)
		}
	}

	// An empty (but non-nil) set must return nothing, rather than everything.
	// This distinction is what the "disabled" boolean parameter encodes.
	none, err := a.getBestTorrents(ctx, bestTorrentsFilter{OnlyTheseTorrents: []int{}})
	if err != nil {
		t.Fatal(err)
	}
	if len(none) != 0 {
		t.Errorf("an empty torrent-id filter returned %d rows, want 0", len(none))
	}
}

// TestGetBestTorrentsDisallowedReleaseTypes checks that filtering works for both
// spellings the API uses (the display string and the numeric id).
func TestGetBestTorrentsDisallowedReleaseTypes(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	limit := 500
	rows, err := a.getBestTorrents(ctx, bestTorrentsFilter{
		LimitResults:           &limit,
		DisallowedReleaseTypes: []releaseType{releaseTypeBootleg, releaseTypeGuestAppearance},
	})
	if err != nil {
		t.Fatal(err)
	}
	for _, td := range rows {
		if td.ReleaseType == releaseTypeBootleg || td.ReleaseType == releaseTypeGuestAppearance {
			t.Errorf("torrent %d has a disallowed release type %+v", td.TorrentID, td.ReleaseType)
		}
	}
}

// TestGetBestTorrentsOrderings checks the two orderings differ, since the SQL is
// assembled by string concatenation and a mistake would silently give one
// ordering twice.
func TestGetBestTorrentsOrderings(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()
	limit := 20

	byWeight, err := a.getBestTorrents(ctx, bestTorrentsFilter{LimitResults: &limit, Ordering: bySeedingWeight})
	if err != nil {
		t.Fatal(err)
	}
	byRelease, err := a.getBestTorrents(ctx, bestTorrentsFilter{LimitResults: &limit, Ordering: byLastReleases})
	if err != nil {
		t.Fatal(err)
	}

	// byLastReleases is ordered by group_id descending.
	for i := 1; i < len(byRelease); i++ {
		if byRelease[i-1].GroupID < byRelease[i].GroupID {
			t.Errorf("byLastReleases is not ordered by group id descending at %d", i)
			break
		}
	}
	if len(byWeight) > 0 && len(byRelease) > 0 && byWeight[0].TorrentID == byRelease[0].TorrentID {
		t.Log("note: both orderings start with the same torrent, which is possible but unlikely")
	}
}

// TestGetBestRecommendationsRuns exercises the recommendations query.
//
// similar_artists is empty in the production database (the feature was never
// run against real data), so this seeds a small synthetic graph in a rolled-back
// transaction rather than asserting on existing rows.
func TestGetBestRecommendationsRuns(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	// With no similar artists there can be no recommendations.
	recs, err := a.getBestRecommendations(ctx, nil, nil)
	if err != nil {
		t.Fatalf("recommendations query failed: %v", err)
	}
	t.Logf("recommendations with the current data: %d", len(recs))

	// The query must at least be valid SQL against the real schema when a limit
	// and disallowed types are supplied, since those are nullable/array
	// parameters.
	limit := 10
	if _, err := a.getBestRecommendations(ctx,
		[]releaseType{releaseTypeBootleg, releaseTypeRemix}, &limit); err != nil {
		t.Fatalf("recommendations query with filters failed: %v", err)
	}
}

// TestGetArtistNameByID checks the lookup used for page titles, including the
// "no such artist" case which must not be an error.
func TestGetArtistNameByID(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	var id int
	var want string
	if err := a.pool.QueryRow(ctx,
		`SELECT artist_id, artist_name FROM redacted.artists LIMIT 1`).Scan(&id, &want); err != nil {
		t.Fatal(err)
	}
	got, err := a.getArtistNameByID(ctx, id)
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}

	missing, err := a.getArtistNameByID(ctx, -1)
	if err != nil {
		t.Errorf("a missing artist must not be an error: %v", err)
	}
	if missing != "" {
		t.Errorf("got %q, want the empty string", missing)
	}
}

// TestGetSettings reads the settings table, which contains a JSON null in the
// production data — a value that must be treated as "not set" rather than
// failing to parse.
func TestGetSettings(t *testing.T) {
	a := newTestApp(t)
	ctx := context.Background()

	s, err := a.getSettings(ctx)
	if err != nil {
		t.Fatal(err)
	}
	// The production row is `freelechTokensExhaustedAt: null`.
	if s.freeleechTokensExhaustedAt != nil {
		t.Logf("freeleech exhausted at %v", *s.freeleechTokensExhaustedAt)
	}
}