Files
Godopu 9bdc9bdd9a feat(arch): unify frontend and backend into single Go backend server with static export
- Configure Next.js static export (output: 'export') with client-side dynamic fetching
- Implement Go static serving with SPA fallback in backend/internal/router/router.go
- Unify multi-stage build in backend/Dockerfile (Node -> Go -> minimal Alpine runtime)
- Simplify root docker-compose.yml to single anl-app service on port 8080
- Update REQUIREMENTS.md DM-03 and AC-17 normative contracts to v3.0 Unified Go Backend
- Restore strict regression verification in Gate 9 (AC-35/36/37) and unittest suite
- All Go unit tests, TypeScript type checks, ESLint, and E2E gates passed clean
2026-08-25 12:54:39 +09:00

568 lines
20 KiB
Go

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("<html>Home</html>"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("<html>Test Page</html>"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("<html>404 Not Found</html>"), 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(), "<html>Home</html>")
// 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(), "<html>Test Page</html>")
// 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(), "<html>404 Not Found</html>")
}
func strconvItoa(i int) string {
return strconv.Itoa(i)
}