The Guard That Says No and the Migration Road Home
A test-only commit: proving the global guard via httptest, and the migration down path on MySQL 5.7 really goes backward, then comes home.
TL;DR
We finally tested two long-ignored paths: the RequireGlobalBidang guard that should block bidang access and the down migrations that had never run. A table-driven unit test now locks in the guard's behavior, including its intentional fail-open for empty contexts. An integration test migrates down to version 56 and back up, verifying columns disappear and reappear correctly.
Writing a down migration file takes five minutes. Trusting it takes a test. In fact, until yesterday not a single process in our environment had ever run that down path.
Honest first guess: down files are dead weight, a formality to keep the tool quiet. Who rolls back a database that often, anyway? That assumption is exactly why two blind spots went untouched: the guard whose job is to say no, and a road home nobody had ever traveled.
The guard that says no
The RequireGlobalBidang middleware guards the global data tables (menus, settings, complaint services): only the ALL scope may pass; any dinas bidang must get a 403. Rejection is a feature, but a rejection that has never been tested is just hope. I wrote a table-driven test with httptest [6]: a recorder plus a fresh request, then the CtxBidang context injected directly, no real server involved. Four explicit cases: scope ALL gets 200, the olahraga bidang 403, the pariwisata bidang 403, and an empty context also 200.
That last case is the one that makes you blink: an empty context passes, fail-open by design. BidangFrom treats a missing context as ALL, because public requests and pre-migration-57 data simply carry no bidang. That contract is now pinned by a test, so nobody can quietly bend it and break callers that already depend on it. The table-driven shape also cuts repetitive code; new cases are just another row in the table [8].
Proving the road home
The second test is heavier: TestMigrations_DownUp. Because it touches real MySQL, it is locked behind the //go:build integration tag; a build constraint is the condition under which a file joins the package [11], so this test only runs when called explicitly, never in the daily unit run. And it does not just go backward: from head, m.Migrate(56) walks down four steps, 000060 to 000059 to 000058 to 000057, LIFO order. The version must stop at 56 with a false dirty flag.
The real proof goes through information_schema.COLUMNS: the four marker columns of the 5B era (users.bidang, entities.attributes, announcements.bidang, registration_submissions.bidang) must be gone from the structure. After that, m.Up() replants the whole chain from 56 to head, and the four columns must come back. ErrNoChange is tolerated [9] because an already-at-head state counts as done. The core shape:
func TestMigrations_DownUp(t *testing.T) {
db := testutil.SetupTestDB(t)
m, err := migrate.New("file://../../migrations", "mysql://"+dsn)
if err != nil {
t.Fatalf("create migrate: %v", err)
}
if err := m.Migrate(56); err != nil {
t.Fatalf("migrate down to 56: %v", err)
}
if v, dirty, err := m.Version(); err != nil || v != 56 || dirty {
t.Fatalf("version after down: v=%d dirty=%v err=%v", v, dirty, err)
}
// information_schema.COLUMNS: the 4 columns of 000057 must be gone
// ... then climb back to head:
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
t.Fatalf("migrate up after down: %v", err)
}
}The DSN must carry multiStatements=true; the golang-migrate MySQL driver needs it to execute several SQL statements in one connection batch [10], and its version-tracking table defaults to the name schema_migrations [10]. Two small details tie this test into habits already running: the t.Run subtest keeps every case name readable in the output [8], and one row in INDEX.md makes sure each test is found by the right runner: unit for the guard, integration for the migration.
Tests usually grow forward: new features, new happy paths. This commit walks the other way: patching two sides that rarely get touched. The guard whose job is to refuse now has a written contract, fail-open exception included. And the migrations road home has been traveled once, down to a safe point and back with nothing left behind. Not theory, not team folklore, but test lines anyone can run.