package service import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestAdminAppIDUpdateClearsCachedToken(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } appid := service.store.Snapshot().AppIDs[0].AppID if err := service.store.SaveMutation(func(st *Store) error { st.Tokens[appid] = Token{AppID: appid, Value: "old-token", ExpiresAt: time.Now().Add(time.Hour)} st.AppIDs[0].LastToken = "old-token" st.AppIDs[0].TokenExpires = time.Now().Add(time.Hour).Format("2006-01-02 15:04:05") return nil }); err != nil { t.Fatal(err) } body := strings.NewReader(`{"appid":"` + appid + `","password":"demo_password","name":"default"}`) req := httptest.NewRequest(http.MethodPost, "/api/appids", body) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } st := service.store.Snapshot() if _, ok := st.Tokens[appid]; ok { t.Fatalf("cached token was not cleared: %#v", st.Tokens[appid]) } if st.AppIDs[0].LastToken != "" || st.AppIDs[0].TokenExpires != "" { t.Fatalf("appid token display was not cleared: %#v", st.AppIDs[0]) } } func TestAdminCardsExportMarksExportedCardsAndPersists(t *testing.T) { dbPath := t.TempDir() + "/onelink_mock.db" service, err := NewService(dbPath) if err != nil { t.Fatal(err) } if _, err := service.store.GenerateCards(2); err != nil { t.Fatal(err) } cards := service.store.Snapshot().Cards req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+cards[0].MSISDN, nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } exportedPage := service.store.QueryCards("", "exported", 1, 20) if exportedPage.Total != 1 || exportedPage.Items[0].MSISDN != cards[0].MSISDN { t.Fatalf("exported cards = %#v", exportedPage) } if all := service.store.QueryCards("", "", 1, 20); len(all.Items) != 2 || all.Items[0].MSISDN != cards[1].MSISDN { t.Fatalf("cards are not newest-first: %#v", all.Items) } reloaded, err := NewService(dbPath) if err != nil { t.Fatal(err) } persisted := reloaded.store.QueryCards("", "exported", 1, 20) if persisted.Total != 1 || persisted.Items[0].MSISDN != cards[0].MSISDN || persisted.Items[0].ExportCheckedAt == "" { t.Fatalf("persisted exported cards = %#v", persisted) } } func TestAdminCardRegionLimitStatusPersistsAndQueries(t *testing.T) { dbPath := t.TempDir() + "/onelink_mock.db" service, err := NewService(dbPath) if err != nil { t.Fatal(err) } if _, err := service.store.GenerateCards(1); err != nil { t.Fatal(err) } card := service.store.Snapshot().Cards[0] card.RegionLimitStatus = "1" body, err := json.Marshal(card) if err != nil { t.Fatal(err) } res := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/cards", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("save status = %d body=%s", res.Code, res.Body.String()) } reloaded, err := NewService(dbPath) if err != nil { t.Fatal(err) } if got := reloaded.store.Snapshot().Cards[0].RegionLimitStatus; got != "1" { t.Fatalf("persisted region limit status = %q, want 1", got) } token := "region-limit-test-token" if err := reloaded.store.SaveMutation(func(st *Store) error { st.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: time.Now().Add(time.Hour)} return nil }); err != nil { t.Fatal(err) } query := func(path string, target any) { method := http.MethodGet var requestBody *bytes.Reader if strings.HasPrefix(path, "/sim/") { method = http.MethodPost requestBody = bytes.NewReader([]byte(`{"simKeys":[{"msisdn":"` + card.MSISDN + `"}],"reqData":{}}`)) } else { requestBody = bytes.NewReader(nil) } queryString := "" if method == http.MethodGet { queryString = "?token=" + token + "&msisdn=" + card.MSISDN } req := httptest.NewRequest(method, path+queryString, requestBody) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() reloaded.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s status = %d body=%s", path, res.Code, res.Body.String()) } if err := json.Unmarshal(res.Body.Bytes(), target); err != nil { t.Fatal(err) } } var oneLink Response query("/v5/ec/query/region-limit-status", &oneLink) result, ok := oneLink.Result[0].(map[string]any) if !ok || len(oneLink.Result) != 1 || result["regionLimitStatus"] != "1" { t.Fatalf("one link region response = %#v", oneLink) } var chanResponses []chanResponse[chanRegionLimitStatus] query("/sim/query/sim-region-limit-status", &chanResponses) if len(chanResponses) != 1 || chanResponses[0].ResData.RegionLimitStatus != "1" { t.Fatalf("chan region response = %#v", chanResponses) } } func TestAdminGenerateAppendsRequestedCount(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } body := strings.NewReader(`{"count":2}`) req := httptest.NewRequest(http.MethodPost, "/api/generate", body) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } var result GenerateResult if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Generated != 2 || result.Total != 2 { t.Fatalf("result = %#v, want generated 2 total 2", result) } if got := service.store.Snapshot(); len(got.Cards) != 2 { t.Fatalf("store cards = %d, want 2", len(got.Cards)) } } func TestAdminGenerateAssignsSelectedGroup(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } create := httptest.NewRequest(http.MethodPost, "/api/card-groups", strings.NewReader(`{"name":"批次一"}`)) create.Header.Set("Content-Type", "application/json") createdRes := httptest.NewRecorder() service.Router().ServeHTTP(createdRes, create) if createdRes.Code != http.StatusOK { t.Fatalf("create group status = %d body=%s", createdRes.Code, createdRes.Body.String()) } var group CardGroup if err := json.Unmarshal(createdRes.Body.Bytes(), &group); err != nil { t.Fatal(err) } generate := httptest.NewRequest(http.MethodPost, "/api/generate", strings.NewReader(fmt.Sprintf(`{"count":2,"groupId":%d}`, group.ID))) generate.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() service.Router().ServeHTTP(res, generate) if res.Code != http.StatusOK { t.Fatalf("generate status = %d body=%s", res.Code, res.Body.String()) } for _, card := range service.store.Snapshot().Cards { if card.GroupID != group.ID || card.GroupName != group.Name { t.Fatalf("card group = %#v, want %d/%s", card, group.ID, group.Name) } } } func TestAdminCardsQueriesByAnyExactIdentifier(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } if _, err := service.store.GenerateCards(2); err != nil { t.Fatal(err) } cards := service.store.Snapshot().Cards for _, keyword := range []string{cards[0].MSISDN, cards[0].ICCID, cards[0].IMSI} { t.Run(keyword, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword="+keyword, nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } var result struct { Items []SIMCard `json:"items"` Total int `json:"total"` } if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Total != 1 || len(result.Items) != 1 || result.Items[0].MSISDN != cards[0].MSISDN { t.Fatalf("result = %#v, want the exact matching card", result) } }) } } func TestAdminCardsQueryReturnsEmptyPageWhenNotFound(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword=not-found", nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } var result struct { Items []SIMCard `json:"items"` Total int `json:"total"` } if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Items == nil || result.Total != 0 || len(result.Items) != 0 { t.Fatalf("result = %#v, want an empty page", result) } } func TestAdminCardsExportUsesInboundExcelHeaders(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } if _, err := service.store.GenerateCards(1); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/cards/export", nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } if contentType := res.Header().Get("Content-Type"); contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" { t.Fatalf("content type = %q", contentType) } workbookText := unzipWorkbookText(t, res.Body.Bytes()) for _, header := range inboundExportHeaders { if !strings.Contains(workbookText, header) { t.Fatalf("export workbook missing header %q in %s", header, workbookText) } } if !strings.Contains(workbookText, "channel_yd_mock") || !strings.Contains(workbookText, "CMIOT-SD-500M-24M-D01") { t.Fatalf("export workbook missing expected generated values: %s", workbookText) } for _, marker := range []string{"sharedStrings.xml", "styles.xml"} { if !zipContainsFile(t, res.Body.Bytes(), "xl/"+marker) { t.Fatalf("export workbook missing xl/%s", marker) } } } func zipContainsFile(t *testing.T, raw []byte, name string) bool { t.Helper() reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { t.Fatal(err) } for _, file := range reader.File { if file.Name == name { return true } } return false } func TestAdminCardsExportFiltersSelectedMSISDNs(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } if _, err := service.store.GenerateCards(3); err != nil { t.Fatal(err) } cards := service.store.Snapshot().Cards selected := cards[0].MSISDN + "," + cards[2].MSISDN req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+selected, nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } workbookText := unzipWorkbookText(t, res.Body.Bytes()) if !strings.Contains(workbookText, cards[0].MSISDN) || !strings.Contains(workbookText, cards[2].MSISDN) { t.Fatalf("export workbook missing selected cards: %s", workbookText) } if strings.Contains(workbookText, cards[1].MSISDN) { t.Fatalf("export workbook included unselected card %s: %s", cards[1].MSISDN, workbookText) } } func TestAdminCardsExportFilteredResultsAcrossPages(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } createdAt := time.Now().Format("2006-01-02 15:04:05") if err = service.store.SaveMutation(func(st *Store) error { st.Groups = append(st.Groups, CardGroup{ID: 7, Name: "group-seven", CreatedAt: createdAt, UpdatedAt: createdAt}, CardGroup{ID: 8, Name: "group-eight", CreatedAt: createdAt, UpdatedAt: createdAt}, ) return nil }); err != nil { t.Fatal(err) } if _, err = service.store.GenerateCardsInGroup(25, 7); err != nil { t.Fatal(err) } if _, err = service.store.GenerateCardsInGroup(5, 8); err != nil { t.Fatal(err) } groupSevenCards := service.store.QueryAllCardsByGroup("", "", 7, true) if len(groupSevenCards) != 25 { t.Fatalf("group 7 cards = %d, want 25", len(groupSevenCards)) } alreadyExported := groupSevenCards[0].MSISDN if err = service.store.SaveMutation(func(st *Store) error { for i := range st.Cards { if st.Cards[i].MSISDN == alreadyExported { st.Cards[i].Exported = true st.Cards[i].ExportCheckedAt = "2026-08-27 00:00:00" break } } return nil }); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&groupId=7&exportStatus=not_exported", nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } workbookText := unzipWorkbookText(t, res.Body.Bytes()) for _, card := range groupSevenCards[1:] { if !strings.Contains(workbookText, card.MSISDN) { t.Fatalf("export workbook missing filtered card %s", card.MSISDN) } } if strings.Contains(workbookText, alreadyExported) { t.Fatalf("export workbook included already exported card %s", alreadyExported) } for _, card := range service.store.QueryAllCardsByGroup("", "", 8, true) { if strings.Contains(workbookText, card.MSISDN) { t.Fatalf("export workbook included card from another group %s", card.MSISDN) } } if exported := service.store.QueryAllCardsByGroup("", "exported", 7, true); len(exported) != 25 { t.Fatalf("exported group 7 cards = %d, want 25", len(exported)) } if exported := service.store.QueryAllCardsByGroup("", "exported", 8, true); len(exported) != 0 { t.Fatalf("exported group 8 cards = %d, want 0", len(exported)) } } func TestAdminCardsExportFilteredResultsByKeyword(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } if _, err = service.store.GenerateCards(3); err != nil { t.Fatal(err) } cards := service.store.Snapshot().Cards req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&keyword="+cards[1].ICCID, nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } workbookText := unzipWorkbookText(t, res.Body.Bytes()) if !strings.Contains(workbookText, cards[1].MSISDN) { t.Fatalf("export workbook missing keyword match %s", cards[1].MSISDN) } for _, card := range []SIMCard{cards[0], cards[2]} { if strings.Contains(workbookText, card.MSISDN) { t.Fatalf("export workbook included non-matching card %s", card.MSISDN) } } } func TestAdminCardsExportFilteredResultsRejectsInvalidFilter(t *testing.T) { service, err := NewService(t.TempDir() + "/onelink_mock.db") if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&exportStatus=invalid", nil) res := httptest.NewRecorder() service.Router().ServeHTTP(res, req) if res.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", res.Code, res.Body.String()) } } func unzipWorkbookText(t *testing.T, raw []byte) string { t.Helper() reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { t.Fatal(err) } var out strings.Builder for _, file := range reader.File { if !strings.HasSuffix(file.Name, ".xml") { continue } rc, err := file.Open() if err != nil { t.Fatal(err) } _, _ = io.Copy(&out, rc) _ = rc.Close() } return out.String() }