package router_test import ( "bytes" "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "testing" "git.godopu.com/lab/landing_page/backend/internal/db" "git.godopu.com/lab/landing_page/backend/internal/models" "git.godopu.com/lab/landing_page/backend/internal/router" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) const testAdminToken = "test-secret-token" func setupTestDB(t *testing.T) *sql.DB { tmpDB := filepath.Join(t.TempDir(), "test.db") database, err := db.Connect(tmpDB) require.NoError(t, err) err = db.RunMigrations(database) require.NoError(t, err) seedDir := filepath.Join("..", "..", "seed", "data") seedDatabase(t, database, seedDir) return database } func seedDatabase(t *testing.T, database *sql.DB, seedDir string) { tx, err := database.BeginTx(context.Background(), nil) require.NoError(t, err) defer tx.Rollback() // 1. Projects pData, err := os.ReadFile(filepath.Join(seedDir, "research_projects.json")) require.NoError(t, err) var projects []struct { Slug string `json:"slug"` Title string `json:"title"` Abstract string `json:"abstract"` Keywords []string `json:"keywords"` StandardsTrack *string `json:"standardsTrack"` StandardsOrg *string `json:"standardsOrg"` Period *string `json:"period"` Funder *string `json:"funder"` } require.NoError(t, json.Unmarshal(pData, &projects)) for _, p := range projects { kwJSON, _ := json.Marshal(p.Keywords) _, err := tx.Exec(` INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, p.Slug, p.Title, p.Abstract, string(kwJSON), p.StandardsTrack, p.StandardsOrg, p.Period, p.Funder) require.NoError(t, err) } // 2. Areas aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json")) require.NoError(t, err) var areas []struct { En string `json:"en"` Kr *string `json:"kr"` } require.NoError(t, json.Unmarshal(aData, &areas)) for idx, a := range areas { _, err := tx.Exec(` INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?) `, a.En, a.Kr, idx+1) require.NoError(t, err) } // 3. Members mData, err := os.ReadFile(filepath.Join(seedDir, "members.json")) require.NoError(t, err) var members []struct { Ko string `json:"ko"` En string `json:"en"` Degree string `json:"degree"` Affiliation *string `json:"affiliation"` Email *string `json:"email"` IsAdvisor bool `json:"is_advisor"` } require.NoError(t, json.Unmarshal(mData, &members)) for idx, m := range members { _, err := tx.Exec(` INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order) VALUES (?, ?, ?, ?, ?, ?, ?) `, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1) require.NoError(t, err) } // 4. Alumni alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json")) require.NoError(t, err) var alumni []struct { Ko string `json:"ko"` En string `json:"en"` Degree string `json:"degree"` GraduatedAt string `json:"graduatedAt"` Major *string `json:"major"` CurrentPosition *string `json:"currentPosition"` } require.NoError(t, json.Unmarshal(alumniData, &alumni)) for _, a := range alumni { _, err := tx.Exec(` INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position) VALUES (?, ?, ?, ?, ?, ?) `, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition) require.NoError(t, err) } // 5. Publications pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json")) require.NoError(t, err) var publications []struct { Category string `json:"category"` Title string `json:"title"` Authors *string `json:"authors"` Venue string `json:"venue"` Volume *string `json:"volume"` PublishedAt string `json:"publishedAt"` KCI bool `json:"kci"` DOI *string `json:"doi"` IsHighlight bool `json:"is_highlight"` } require.NoError(t, json.Unmarshal(pubData, &publications)) for _, p := range publications { _, err := tx.Exec(` INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight) require.NoError(t, err) } // 6. Patents patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json")) require.NoError(t, err) var patents []struct { Title string `json:"title"` Inventors string `json:"inventors"` ApplicationNo string `json:"applicationNo"` ApplicationAt string `json:"applicationAt"` RegistrationNo *string `json:"registrationNo"` RegistrationAt *string `json:"registrationAt"` Country string `json:"country"` PublishedAt string `json:"publishedAt"` } require.NoError(t, json.Unmarshal(patData, &patents)) for _, p := range patents { _, err := tx.Exec(` INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt) require.NoError(t, err) } // 7. Lectures lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json")) require.NoError(t, err) var semesters []struct { Term string `json:"term"` Year int `json:"year"` Courses []struct { Code string `json:"code"` En string `json:"en"` Note *string `json:"note"` } `json:"courses"` } require.NoError(t, json.Unmarshal(lecData, &semesters)) for _, s := range semesters { res, err := tx.Exec(`INSERT INTO semesters (year, term) VALUES (?, ?)`, s.Year, s.Term) require.NoError(t, err) semID, _ := res.LastInsertId() for cIdx, c := range s.Courses { _, err := tx.Exec(` INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order) VALUES (?, ?, ?, ?, ?, ?) `, semID, c.Code, c.En, nil, c.Note, cIdx+1) require.NoError(t, err) } } // 8. Standards stdData, err := os.ReadFile(filepath.Join(seedDir, "standards.json")) require.NoError(t, err) var standards []struct { Org string `json:"org"` Full string `json:"full"` Scope string `json:"scope"` Period string `json:"period"` Role *string `json:"role"` Projects []struct { WG string `json:"wg"` Name string `json:"name"` Documents []struct { Title string `json:"title"` Ref string `json:"ref"` Status string `json:"status"` PublishedAt *string `json:"publishedAt"` } `json:"documents"` } `json:"projects"` } require.NoError(t, json.Unmarshal(stdData, &standards)) for bIdx, b := range standards { res, err := tx.Exec(` INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order) VALUES (?, ?, ?, ?, ?, ?) `, b.Org, b.Full, b.Scope, b.Period, b.Role, bIdx+1) require.NoError(t, err) bodyID, _ := res.LastInsertId() for _, p := range b.Projects { for _, d := range p.Documents { _, err := tx.Exec(` INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, bodyID, p.WG, p.Name, d.Title, d.Ref, d.Status, d.PublishedAt) require.NoError(t, err) } } } require.NoError(t, tx.Commit()) } type APIResponse[T any] struct { Data T `json:"data"` Error *struct { Message string `json:"message"` } `json:"error"` } func ptrString(s string) *string { return &s } func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder { var reqBody []byte if body != nil { reqBody, _ = json.Marshal(body) } req := httptest.NewRequest(method, target, bytes.NewReader(reqBody)) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } func TestHealthCheck(t *testing.T) { db := setupTestDB(t) defer db.Close() r := router.SetupRouter(db, testAdminToken) w := doRequest(r, "GET", "/api/v1/health", nil, "") assert.Equal(t, http.StatusOK, w.Code) } func TestAdminAuthMiddleware(t *testing.T) { db := setupTestDB(t) defer db.Close() // A. Configured with explicit valid token r := router.SetupRouter(db, testAdminToken) // 1. Unauthenticated request to /admin/verify should return 401 wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "") assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code) // 2. Guessable fallback token "admin123" must return 401 wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123") assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code) // 3. Wrong token request to /admin/verify should return 401 wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token") assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code) // 4. Valid token request to /admin/verify should return 200 wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken) assert.Equal(t, http.StatusOK, wValidAuth.Code) // 5. Public GET routes remain accessible without auth wPublic := doRequest(r, "GET", "/api/v1/members", nil, "") assert.Equal(t, http.StatusOK, wPublic.Code) // B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts rEmptyToken := router.SetupRouter(db, "") // 6. Any token on verify should return 401 wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123") assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code) wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "") assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code) // 7. Mutating POST on empty router should return 401 newProj := models.ResearchProject{ Slug: "test-unauth", Title: "Unauthorized Title", Abstract: "Unauthorized abstract.", Keywords: []string{"unauth"}, } wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123") assert.Equal(t, http.StatusUnauthorized, wMutate.Code) // 8. Public GET routes still work on empty admin token router wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "") assert.Equal(t, http.StatusOK, wPublicEmpty.Code) } func TestResearchProjectsCRUD(t *testing.T) { db := setupTestDB(t) defer db.Close() r := router.SetupRouter(db, testAdminToken) // 1. Create Project newProj := models.ResearchProject{ Slug: "test-crud-proj", Title: "Test Project Title", Abstract: "Test project abstract for CRUD verification.", Keywords: []string{"test", "crud"}, } wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken) assert.Equal(t, http.StatusCreated, wCreate.Code) var createResp APIResponse[models.ResearchProject] require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) createdID := createResp.Data.ID assert.True(t, createdID > 0) // 2. Update Project newProj.Title = "Updated Project Title" wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken) assert.Equal(t, http.StatusOK, wUpdate.Code) // 3. Delete Project wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken) assert.Equal(t, http.StatusNoContent, wDelete.Code) } func TestResearchAreasDisplayOrderShift(t *testing.T) { db := setupTestDB(t) defer db.Close() r := router.SetupRouter(db, testAdminToken) // 1. Fetch initial research areas wInit := doRequest(r, "GET", "/api/v1/research-areas", nil, "") assert.Equal(t, http.StatusOK, wInit.Code) var initResp APIResponse[[]models.ResearchArea] require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) initAreas := initResp.Data initCount := len(initAreas) require.NotEmpty(t, initAreas) // Verify initial areas are contiguous 1..N for idx, a := range initAreas { assert.Equal(t, idx+1, a.DisplayOrder) } // 2. Create at target display_order = 2 (should shift existing 2..N to 3..N+1 and normalize 1..N+1) newArea := models.ResearchArea{ NameEn: "Quantum Internet Protocols", NameKr: ptrString("양자 인터넷 프로토콜"), DisplayOrder: 2, } wCreate := doRequest(r, "POST", "/api/v1/research-areas", newArea, testAdminToken) assert.Equal(t, http.StatusCreated, wCreate.Code) var createResp APIResponse[models.ResearchArea] require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) createdID := createResp.Data.ID assert.Equal(t, 2, createResp.Data.DisplayOrder) // Fetch updated list and assert display_order shifted correctly and gapless wAfterCreate := doRequest(r, "GET", "/api/v1/research-areas", nil, "") assert.Equal(t, http.StatusOK, wAfterCreate.Code) var afterCreateResp APIResponse[[]models.ResearchArea] require.NoError(t, json.Unmarshal(wAfterCreate.Body.Bytes(), &afterCreateResp)) afterCreateAreas := afterCreateResp.Data assert.Equal(t, initCount+1, len(afterCreateAreas)) assert.Equal(t, "Quantum Internet Protocols", afterCreateAreas[1].NameEn) for idx, a := range afterCreateAreas { assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have display_order %d", idx, idx+1) } // 3. Update display_order of the created item from position 2 to position 5 updateArea := models.ResearchArea{ NameEn: "Quantum Internet Protocols Updated", DisplayOrder: 5, } wUpdate := doRequest(r, "PUT", "/api/v1/research-areas/"+strconvItoa(createdID), updateArea, testAdminToken) assert.Equal(t, http.StatusOK, wUpdate.Code) wAfterUpdate := doRequest(r, "GET", "/api/v1/research-areas", nil, "") var afterUpdateResp APIResponse[[]models.ResearchArea] require.NoError(t, json.Unmarshal(wAfterUpdate.Body.Bytes(), &afterUpdateResp)) afterUpdateAreas := afterUpdateResp.Data assert.Equal(t, initCount+1, len(afterUpdateAreas)) assert.Equal(t, "Quantum Internet Protocols Updated", afterUpdateAreas[4].NameEn) assert.Equal(t, 5, afterUpdateAreas[4].DisplayOrder) for idx, a := range afterUpdateAreas { assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have contiguous display_order %d after update", idx, idx+1) } // 4. Delete the item and verify gapless 1..N normalization back to initCount wDel := doRequest(r, "DELETE", "/api/v1/research-areas/"+strconvItoa(createdID), nil, testAdminToken) assert.Equal(t, http.StatusNoContent, wDel.Code) wAfterDel := doRequest(r, "GET", "/api/v1/research-areas", nil, "") var afterDelResp APIResponse[[]models.ResearchArea] require.NoError(t, json.Unmarshal(wAfterDel.Body.Bytes(), &afterDelResp)) afterDelAreas := afterDelResp.Data assert.Equal(t, initCount, len(afterDelAreas)) for idx, a := range afterDelAreas { assert.Equal(t, idx+1, a.DisplayOrder, "Item %d should have contiguous display_order %d after delete", idx, idx+1) } } func TestMembersAndAlumniCRUD(t *testing.T) { db := setupTestDB(t) defer db.Close() r := router.SetupRouter(db, testAdminToken) // 1. Create Member with Invalid Degree -> 400 badMember := models.Member{ NameKo: "홍길동", NameEn: "Gildong Hong", Degree: "InvalidDegree", } wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken) assert.Equal(t, http.StatusBadRequest, wBad.Code) // 2. Create Valid Member -> 201 validMember := models.Member{ NameKo: "홍길동", NameEn: "Gildong Hong", Degree: "PhDStudent", } wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken) assert.Equal(t, http.StatusCreated, wCreate.Code) var createResp APIResponse[models.Member] require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) mID := createResp.Data.ID // 3. Delete Member -> 204 wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken) assert.Equal(t, http.StatusNoContent, wDelete.Code) // 4. Create Alumnus -> 201 validAlumnus := models.Alumnus{ NameKo: "김졸업", NameEn: "Grad Kim", Degree: "MS", GraduatedAt: "2024-02", } wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken) assert.Equal(t, http.StatusCreated, wCreateAl.Code) var alResp APIResponse[models.Alumnus] require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp)) alID := alResp.Data.ID // 5. Delete Alumnus -> 204 wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken) assert.Equal(t, http.StatusNoContent, wDelAl.Code) } func TestCascadeDeletes(t *testing.T) { db := setupTestDB(t) defer db.Close() r := router.SetupRouter(db, testAdminToken) // 1. Create a semester newSem := models.Semester{Year: 2030, Term: "Spring"} wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken) assert.Equal(t, http.StatusCreated, wSem.Code) var semResp APIResponse[models.Semester] require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp)) semID := semResp.Data.ID // 2. Create courses under this semester course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"} course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"} wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken) assert.Equal(t, http.StatusCreated, wCourse1.Code) wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken) assert.Equal(t, http.StatusCreated, wCourse2.Code) // Verify courses exist in DB var courseCount int err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount) require.NoError(t, err) assert.Equal(t, 2, courseCount) // 3. Delete semester wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken) assert.Equal(t, http.StatusNoContent, wDelSem.Code) // 4. Verify cascade delete removed courses err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount) require.NoError(t, err) assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted") } func TestStaticServingAndRedirects(t *testing.T) { db := setupTestDB(t) defer db.Close() r := router.SetupRouter(db, testAdminToken) // 1. /intro 308 permanent redirect to / wIntro := doRequest(r, "GET", "/intro", nil, "") assert.Equal(t, http.StatusPermanentRedirect, wIntro.Code) assert.Equal(t, "/", wIntro.Header().Get("Location")) // 2. Unknown API route returns 404 JSON (not HTML) wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "") assert.Equal(t, http.StatusNotFound, wAPI404.Code) var apiErr map[string]any require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr)) assert.Contains(t, apiErr, "error") // 3. Create mock web directory to verify static HTML serving webDir := "./web" require.NoError(t, os.MkdirAll(filepath.Join(webDir, "test-page"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("Home"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("Test Page"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("404 Not Found"), 0644)) defer os.RemoveAll(webDir) // Test root URL / wRoot := doRequest(r, "GET", "/", nil, "") assert.Equal(t, http.StatusOK, wRoot.Code) assert.Contains(t, wRoot.Body.String(), "Home") // Test sub-route /test-page wTestPage := doRequest(r, "GET", "/test-page", nil, "") assert.Equal(t, http.StatusOK, wTestPage.Code) assert.Contains(t, wTestPage.Body.String(), "Test Page") // Test unknown page -> 404 HTML wUnknown := doRequest(r, "GET", "/completely-unknown-route", nil, "") assert.Equal(t, http.StatusNotFound, wUnknown.Code) assert.Contains(t, wUnknown.Body.String(), "404 Not Found") } func strconvItoa(i int) string { return strconv.Itoa(i) }