Skip to content

Commit 3b47360

Browse files
committed
SQL cleanup, add NM/WNM titles, fix IO purity, and expand test coverage
- V0019 migration: drop redundant cache_key_idx, replace EXCEPTION-based AVERAGE with CASE WHEN, convert implicit cross-joins to explicit JOINs in federation views, drop 27 redundant indexes on federations_summary, document intentional no-FK on player_history.federation_id - Add NM/WNM to Title enum in both Scala domain and Smithy spec - Wrap OffsetDateTime.now() in IO in Crawler.fetchAndSave - Add 40+ new tests: parser edge cases, syncer status logic, player hash stability, DB sorting/filtering/batch ops, history rating queries, hash-based diffing, and CLI history ingestor integration
1 parent d865c91 commit 3b47360

11 files changed

Lines changed: 686 additions & 6 deletions

File tree

modules/api/src/main/smithy/_global.smithy

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,15 @@ integer PageSize
109109

110110
enum Title {
111111
GM = "GM"
112-
WGM = "WGM"
113112
IM = "IM"
114-
WIM = "WIM"
115113
FM = "FM"
116-
WFM = "WFM"
117114
CM = "CM"
115+
NM = "NM"
116+
WGM = "WGM"
117+
WIM = "WIM"
118+
WFM = "WFM"
118119
WCM = "WCM"
120+
WNM = "WNM"
119121
}
120122

121123
enum OtherTitle {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package fide
2+
package cli
3+
package test
4+
5+
import cats.effect.IO
6+
import cats.effect.kernel.Resource
7+
import cats.syntax.all.*
8+
import com.comcast.ip4s.*
9+
import fide.db.test.Containers
10+
import fide.db.{ Db, HistoryDb, PostgresConfig }
11+
import fide.domain.Models.*
12+
import fide.types.*
13+
import io.github.iltotore.iron.*
14+
import org.typelevel.log4cats.Logger
15+
import org.typelevel.log4cats.noop.NoOpLogger
16+
import weaver.*
17+
18+
import java.nio.file.{ Files as JFiles, Path }
19+
20+
object HistoryIngestorSuite extends SimpleIOSuite:
21+
22+
given Logger[IO] = NoOpLogger[IO]
23+
24+
private val defaultPage = Pagination(PageNumber(1), PageSize(100))
25+
26+
// Dummy postgres config — never used since HistoryIngestor receives db/historyDb directly
27+
private val dummyPg = PostgresConfig(ip"0.0.0.0", port"5432", "", "", "", 1, "fide", false, false)
28+
29+
private def mkConfig(
30+
dir: Path,
31+
startMonth: Option[YearMonth] = none,
32+
endMonth: Option[YearMonth] = none
33+
): IngestConfig =
34+
IngestConfig(dir, startMonth, endMonth, dummyPg, 100)
35+
36+
private def resourceWithFeds: Resource[IO, (HistoryDb, Db)] =
37+
Containers.createResource.map(x => (HistoryDb(x.postgres, 100), Db(x.postgres)))
38+
39+
private def writeCsv(dir: java.nio.file.Path, filename: String, content: String): IO[Unit] =
40+
IO:
41+
JFiles.writeString(dir.resolve(filename), content)
42+
()
43+
44+
private val csvHeader =
45+
"id,name,title,womenTitle,otherTitles,standard,standardK,rapid,rapidK,blitz,blitzK,gender,birthYear,active,federationId"
46+
47+
private def playerRow(
48+
id: Int,
49+
name: String,
50+
fedId: String = "USA",
51+
standard: Int = 2700,
52+
active: Boolean = true
53+
) =
54+
s"$id,$name,GM,,,${standard},40,2600,40,2500,40,M,1990,$active,$fedId"
55+
56+
// fide-ced: CSV discovery tests
57+
test("discovers only yyyy-MM.csv files, ignores others"):
58+
resourceWithFeds.use: (historyDb, db) =>
59+
IO(JFiles.createTempDirectory("fide-test")).flatMap: tmpDir =>
60+
for
61+
_ <- writeCsv(tmpDir, "2024-01.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
62+
_ <- writeCsv(tmpDir, "notes.csv", s"$csvHeader\n${playerRow(2, "Bob")}")
63+
_ <- writeCsv(tmpDir, "backup.csv", s"$csvHeader\n${playerRow(3, "Charlie")}")
64+
config = mkConfig(tmpDir)
65+
ingestor = HistoryIngestor(historyDb, db, config)
66+
_ <- ingestor.ingest
67+
months <- historyDb.availableMonths
68+
yield expect(months.size == 1) and
69+
expect(months.head == YearMonth(2024, 1))
70+
71+
test("startMonth filter only ingests files >= startMonth"):
72+
resourceWithFeds.use: (historyDb, db) =>
73+
IO(JFiles.createTempDirectory("fide-test")).flatMap: tmpDir =>
74+
for
75+
_ <- writeCsv(tmpDir, "2024-01.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
76+
_ <- writeCsv(tmpDir, "2024-02.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
77+
_ <- writeCsv(tmpDir, "2024-03.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
78+
config = mkConfig(tmpDir, startMonth = YearMonth(2024, 2).some)
79+
ingestor = HistoryIngestor(historyDb, db, config)
80+
_ <- ingestor.ingest
81+
months <- historyDb.availableMonths
82+
yield expect(months.size == 2) and
83+
expect(!months.contains(YearMonth(2024, 1)))
84+
85+
test("endMonth filter only ingests files <= endMonth"):
86+
resourceWithFeds.use: (historyDb, db) =>
87+
IO(JFiles.createTempDirectory("fide-test")).flatMap: tmpDir =>
88+
for
89+
_ <- writeCsv(tmpDir, "2024-01.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
90+
_ <- writeCsv(tmpDir, "2024-02.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
91+
_ <- writeCsv(tmpDir, "2024-03.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
92+
config = mkConfig(tmpDir, endMonth = YearMonth(2024, 2).some)
93+
ingestor = HistoryIngestor(historyDb, db, config)
94+
_ <- ingestor.ingest
95+
months <- historyDb.availableMonths
96+
yield expect(months.size == 2) and
97+
expect(!months.contains(YearMonth(2024, 3)))
98+
99+
// fide-u4h: end-to-end integration test
100+
test("full pipeline: CSV to queryable history"):
101+
resourceWithFeds.use: (historyDb, db) =>
102+
IO(JFiles.createTempDirectory("fide-test")).flatMap: tmpDir =>
103+
val jan2024 = YearMonth(2024, 1)
104+
val feb2024 = YearMonth(2024, 2)
105+
for
106+
_ <- writeCsv(
107+
tmpDir,
108+
"2024-01.csv",
109+
s"$csvHeader\n${playerRow(1, "Alice")}\n${playerRow(2, "Bob")}"
110+
)
111+
_ <- writeCsv(tmpDir, "2024-02.csv", s"$csvHeader\n${playerRow(1, "Alice", standard = 2750)}")
112+
config = mkConfig(tmpDir)
113+
ingestor = HistoryIngestor(historyDb, db, config)
114+
_ <- ingestor.ingest
115+
// Verify available months
116+
months <- historyDb.availableMonths
117+
// Verify player_info populated
118+
janPlayers <- historyDb.allPlayers(
119+
jan2024,
120+
Sorting(SortBy.Name, Order.Asc),
121+
defaultPage,
122+
PlayerFilter.default
123+
)
124+
// Verify per-month snapshots
125+
febPlayers <- historyDb.allPlayers(
126+
feb2024,
127+
Sorting(SortBy.Name, Order.Asc),
128+
defaultPage,
129+
PlayerFilter.default
130+
)
131+
// Verify playerById
132+
alice <- historyDb.playerById(PlayerId(1), feb2024)
133+
// Verify federation populated
134+
feds <- db.allFederations
135+
yield expect(months.size == 2) and
136+
expect(janPlayers.size == 2) and
137+
expect(janPlayers.head.name == "Alice") and
138+
expect(febPlayers.size == 1) and
139+
expect(alice.isDefined) and
140+
expect(alice.get.standard.contains(Rating(2750))) and
141+
expect(feds.exists(_.id == FederationId("USA")))
142+
143+
test("re-running ingest is idempotent"):
144+
resourceWithFeds.use: (historyDb, db) =>
145+
IO(JFiles.createTempDirectory("fide-test")).flatMap: tmpDir =>
146+
val jan2024 = YearMonth(2024, 1)
147+
for
148+
_ <- writeCsv(tmpDir, "2024-01.csv", s"$csvHeader\n${playerRow(1, "Alice")}")
149+
config = mkConfig(tmpDir)
150+
ingestor = HistoryIngestor(historyDb, db, config)
151+
_ <- ingestor.ingest
152+
_ <- ingestor.ingest // second run
153+
players <- historyDb.allPlayers(
154+
jan2024,
155+
Sorting(SortBy.Name, Order.Asc),
156+
defaultPage,
157+
PlayerFilter.default
158+
)
159+
yield expect(players.size == 1) // no duplicates

modules/crawler/src/main/scala/Crawler.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ object Crawler:
5454
case _ => info"Skipping crawling as the data is up to date".as(CrawlStatus.Skipped)
5555

5656
def fetchAndSave(timestamp: Option[String]): IO[Unit] =
57-
val now = OffsetDateTime.now()
5857
for
58+
now <- IO.realTimeZonedDateTime.map(_.toOffsetDateTime)
5959
_ <- info"Start crawling"
6060
startAt <- IO.monotonic
6161
cachedHashes <- playerHashCache.get

modules/crawler/src/test/scala/ParserTest.scala

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ package test
44

55
import cats.effect.IO
66
import cats.syntax.all.*
7-
import fide.domain.OtherTitle
7+
import fide.domain.{ Gender, OtherTitle, Title }
88
import org.typelevel.log4cats.Logger
99
import weaver.*
1010

@@ -50,4 +50,50 @@ object ParserTest extends SimpleIOSuite:
5050
expect.same(Downloader.sanitizeName("Amar,, Kahtan"), "Amar Kahtan".some) &&
5151
expect.same(Downloader.sanitizeName("-,-"), none)
5252

53+
test("player with no federation"):
54+
parse(
55+
"1478800 Aagaard, Christian M 1999 "
56+
).map: result =>
57+
expect(result.get.federationId.isEmpty)
58+
59+
test("player with NON federation is excluded"):
60+
parse(
61+
"1478800 Aagaard, Christian NON M 1999 "
62+
).map: result =>
63+
expect(result.get.federationId.isEmpty)
64+
65+
test("player with title and women title"):
66+
parse(
67+
"10001492 Ojok, Patrick UGA F GM WGM 1638 20 1932 20 1926 20 1974 "
68+
).map: result =>
69+
val p = result.get
70+
expect(p.title.contains(Title.GM)) and
71+
expect(p.womenTitle.contains(Title.WGM))
72+
73+
test("empty line is skipped"):
74+
parse("").map(result => expect(result.isEmpty))
75+
76+
test("whitespace-only line is skipped"):
77+
parse(" ").map(result =>
78+
expect(result.isEmpty)
79+
)
80+
81+
test("birth year below 1000 is excluded"):
82+
parse(
83+
"10001492 Ojok, Patrick UGA M 1638 0 20 1932 0 20 1926 0 20 0999 "
84+
).map: result =>
85+
expect(result.get.birthYear.isEmpty)
86+
87+
test("birth year above current year is excluded"):
88+
parse(
89+
"10001492 Ojok, Patrick UGA M 1638 0 20 1932 0 20 1926 0 20 9999 "
90+
).map: result =>
91+
expect(result.get.birthYear.isEmpty)
92+
93+
test("gender is parsed"):
94+
parse(
95+
"10001492 Ojok, Patrick UGA F 1638 0 20 1932 0 20 1926 0 20 1974 "
96+
).map: result =>
97+
expect(result.get.gender.contains(Gender.Female))
98+
5399
private def parse(s: String) = Downloader.parseLine(s).map(_.map(_._1))
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package fide
2+
package crawler
3+
package test
4+
5+
import cats.effect.IO
6+
import fide.crawler.Syncer.Status
7+
import weaver.*
8+
9+
object SyncerTest extends SimpleIOSuite:
10+
11+
test("Status: same local and remote is UpToDate"):
12+
IO(expect(Status(Some("a"), Some("a")) == Status.UpToDate))
13+
14+
test("Status: different local and remote is OutDated"):
15+
IO:
16+
val status = Status(Some("a"), Some("b"))
17+
expect(status == Status.OutDated(Some("b")))
18+
19+
test("Status: no local, some remote is OutDated"):
20+
IO:
21+
val status = Status(None, Some("b"))
22+
expect(status == Status.OutDated(Some("b")))
23+
24+
test("Status: no local, no remote is OutDated"):
25+
IO:
26+
val status = Status(None, None)
27+
expect(status == Status.OutDated(None))

0 commit comments

Comments
 (0)