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
package main

import (
	"encoding/json"
	"testing"
)

// TestParseTourGroupsNormalisation pins the JSON normalisation.
//
// This is the highest-risk transformation in the port: the normalised object is
// stored in redacted.torrents_json.full_json_result, from which PostgreSQL
// computes the STORED generated columns seeding_weight and artist_ids. Getting
// a key name wrong here does not fail anywhere — it silently changes the
// ranking of every torrent (a missing "snatches" makes the weight NULL, a
// missing "artists" empties artist_ids and the torrent vanishes from the
// artist pages).
func TestParseTourGroupsNormalisation(t *testing.T) {
	// Shaped like an action=browse response, including the "snatched" spelling
	// that some torrent objects use.
	raw := json.RawMessage(`[
	  {
	    "groupId": 100,
	    "groupName": "Thriller",
	    "groupYear": 1982,
	    "releaseType": "Album",
	    "artists": [{"id": 7, "name": "Michael Jackson"}],
	    "torrents": [
	      {"torrentId": 5001, "encoding": "V0 (VBR)", "seeders": 10, "snatched": 5},
	      {"torrentId": 5002, "encoding": "Lossless", "seeders": 2, "snatches": 1}
	    ]
	  }
	]`)

	groups, err := parseTourGroups(raw, searchFieldNames)
	if err != nil {
		t.Fatal(err)
	}
	if len(groups) != 1 {
		t.Fatalf("got %d groups, want 1", len(groups))
	}
	g := groups[0]
	if g.GroupID != 100 || g.GroupName != "Thriller" {
		t.Errorf("group = %d/%q, want 100/Thriller", g.GroupID, g.GroupName)
	}

	// The torrents must NOT be part of the stored group JSON: they are stored
	// separately, and duplicating them would double the database size.
	var groupObj map[string]json.RawMessage
	if err := json.Unmarshal(g.FullJSONResult, &groupObj); err != nil {
		t.Fatal(err)
	}
	if _, present := groupObj["torrents"]; present {
		t.Error("stored group JSON must not contain the torrents array")
	}
	if _, present := groupObj["artists"]; !present {
		t.Error("stored group JSON must keep the artists array")
	}

	if len(g.Torrents) != 2 {
		t.Fatalf("got %d torrents, want 2", len(g.Torrents))
	}

	// "snatched" must have become "snatches", because calc_seeding_weight reads
	// full_json_result->'snatches'.
	var first map[string]json.RawMessage
	if err := json.Unmarshal(g.Torrents[0].FullJSONResult, &first); err != nil {
		t.Fatal(err)
	}
	if _, present := first["snatched"]; present {
		t.Error(`"snatched" must be renamed to "snatches"`)
	}
	if got := string(first["snatches"]); got != "5" {
		t.Errorf(`snatches = %s, want 5`, got)
	}
	if got := string(first["torrentId"]); got != "5001" {
		t.Errorf("torrentId = %s, want 5001", got)
	}
	if g.Torrents[0].TorrentID != 5001 {
		t.Errorf("TorrentID = %d, want 5001", g.Torrents[0].TorrentID)
	}

	// An already-correct "snatches" must survive untouched.
	var second map[string]json.RawMessage
	if err := json.Unmarshal(g.Torrents[1].FullJSONResult, &second); err != nil {
		t.Fatal(err)
	}
	if got := string(second["snatches"]); got != "1" {
		t.Errorf("snatches = %s, want 1", got)
	}
}

// TestParseTourGroupsArtistEndpoint covers the other field naming: action=artist
// returns "torrent" (singular) with an "id" key instead of "torrentId".
func TestParseTourGroupsArtistEndpoint(t *testing.T) {
	raw := json.RawMessage(`[
	  {
	    "groupId": 200,
	    "groupName": "Cherish",
	    "torrent": [
	      {"id": 6001, "encoding": "320", "seeders": 3, "snatches": 2}
	    ]
	  }
	]`)

	groups, err := parseTourGroups(raw, artistFieldNames)
	if err != nil {
		t.Fatal(err)
	}
	if len(groups) != 1 || len(groups[0].Torrents) != 1 {
		t.Fatalf("unexpected shape: %+v", groups)
	}
	if groups[0].Torrents[0].TorrentID != 6001 {
		t.Errorf("TorrentID = %d, want 6001", groups[0].Torrents[0].TorrentID)
	}
	// The id key must be normalised, so that both endpoints produce the same
	// stored shape.
	var obj map[string]json.RawMessage
	if err := json.Unmarshal(groups[0].Torrents[0].FullJSONResult, &obj); err != nil {
		t.Fatal(err)
	}
	if _, present := obj["id"]; present {
		t.Error(`"id" must be renamed to "torrentId"`)
	}
	if got := string(obj["torrentId"]); got != "6001" {
		t.Errorf("torrentId = %s, want 6001", got)
	}
}

// TestParseTourGroupsSkipsNonTorrentEntries: the API mixes non-torrent items
// into the results (guitar tabs and the like). They must be skipped rather than
// erroring the whole page.
func TestParseTourGroupsSkipsNonTorrentEntries(t *testing.T) {
	raw := json.RawMessage(`[
	  {"groupId": 1, "groupName": "has torrents", "torrents": [{"torrentId": 1, "seeders": 1, "snatches": 1}]},
	  {"groupId": 2, "groupName": "guitar tabs, no torrents"}
	]`)
	groups, err := parseTourGroups(raw, searchFieldNames)
	if err != nil {
		t.Fatal(err)
	}
	if len(groups) != 1 {
		t.Fatalf("got %d groups, want 1 (the non-torrent entry must be skipped)", len(groups))
	}
	if groups[0].GroupID != 1 {
		t.Errorf("kept the wrong group: %d", groups[0].GroupID)
	}
}

// TestJSONIntAcceptsStrings: the API is not consistent about quoting numbers.
func TestJSONInt(t *testing.T) {
	for _, c := range []struct {
		in   string
		want int
	}{
		{`123`, 123},
		{`"123"`, 123},
	} {
		got, err := jsonInt(json.RawMessage(c.in))
		if err != nil {
			t.Errorf("jsonInt(%s): %v", c.in, err)
			continue
		}
		if got != c.want {
			t.Errorf("jsonInt(%s) = %d, want %d", c.in, got, c.want)
		}
	}
	if _, err := jsonInt(json.RawMessage(`"abc"`)); err == nil {
		t.Error("expected an error for a non-numeric string")
	}
}

// TestRetryAfter pins the clamping: an unbounded Retry-After would hang a
// request for as long as the server likes.
func TestRetryAfter(t *testing.T) {
	for _, c := range []struct {
		in      string
		wantSec float64
	}{
		{"", 2},     // missing -> default
		{"abc", 2},  // unparseable -> default
		{"0", 0},    //
		{"5", 5},    //
		{"600", 10}, // clamped
		{"-3", 0},   // clamped
	} {
		if got := retryAfter(c.in).Seconds(); got != c.wantSec {
			t.Errorf("retryAfter(%q) = %vs, want %vs", c.in, got, c.wantSec)
		}
	}
}

// TestReleaseTypeRoundTrip: both spellings must resolve, and unknown values must
// survive rather than being dropped.
func TestReleaseType(t *testing.T) {
	if got := releaseTypeFromTextOrIntKey("Album"); got != releaseTypeAlbum {
		t.Errorf(`from "Album" = %+v`, got)
	}
	if got := releaseTypeFromTextOrIntKey("1"); got != releaseTypeAlbum {
		t.Errorf(`from "1" = %+v`, got)
	}
	unknown := releaseTypeFromTextOrIntKey("Field Recording")
	if unknown.IntKey != -1 || unknown.StringKey != "Field Recording" {
		t.Errorf("unknown release type should be preserved, got %+v", unknown)
	}

	// The SQL comparison needs both spellings of every disallowed type.
	got := disallowedReleaseTypeStrings([]releaseType{releaseTypeBootleg, releaseTypeDJMix})
	want := []string{"Bootleg", "14", "DJ Mix", "19"}
	if len(got) != len(want) {
		t.Fatalf("got %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("got %v, want %v", got, want)
			break
		}
	}
}

// TestReleaseTypesOverapproximated: the search parameter must exclude the given
// type and include ids beyond the currently known ones.
func TestReleaseTypesOverapproximated(t *testing.T) {
	s := releaseTypesOverapproximatedWithout(releaseTypeCompilation)
	ids := map[string]bool{}
	for _, part := range splitComma(s) {
		ids[part] = true
	}
	if ids["7"] {
		t.Error("compilation (7) should be excluded")
	}
	if !ids["1"] || !ids["1024"] {
		t.Error("known types should be included")
	}
	// Over-approximation: ids not currently assigned are included on purpose,
	// so new upstream types are not silently filtered out.
	if !ids["50"] || !ids["1050"] {
		t.Error("the range should be over-approximated")
	}
}

func splitComma(s string) []string {
	var out []string
	cur := ""
	for _, r := range s {
		if r == ',' {
			out = append(out, cur)
			cur = ""
			continue
		}
		cur += string(r)
	}
	if cur != "" {
		out = append(out, cur)
	}
	return out
}

// TestParseSettingsTime: the settings table may contain timestamps written by
// the Haskell version (its UTCTime Show instance) as well as RFC 3339 ones
// written by this port, since both can run against the same database.
func TestParseSettingsTime(t *testing.T) {
	for _, in := range []string{
		"2025-01-09T12:34:56Z",
		"2025-01-09T12:34:56.789Z",
		"2025-01-09 12:34:56.789 UTC",
		"2025-01-09 12:34:56 UTC",
	} {
		if _, err := parseSettingsTime(in); err != nil {
			t.Errorf("parseSettingsTime(%q): %v", in, err)
		}
	}
	if _, err := parseSettingsTime("not a time"); err == nil {
		t.Error("expected an error for garbage")
	}
}