Просмотр исходного кода

mgmt api: list AIM directory entries (#219)

* state: list users with directory info

* mgmt api: list and clear user directory entries

* mgmt api: move directory listing to /directory/aim, drop directory delete
Lucas 20 часов назад
Родитель
Сommit
645611a8c4

+ 53 - 0
api.yml

@@ -829,6 +829,59 @@ paths:
                   message:
                     type: string
 
+  /directory/aim:
+    get:
+      summary: Get all AIM user directory entries
+      description: Retrieve the AIM user directory entries of all users who have directory info or keywords set.
+      responses:
+        '200':
+          description: Successful response containing a list of directory entries.
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    screen_name:
+                      type: string
+                      description: The user's display screen name.
+                    first_name:
+                      type: string
+                      description: The user's first name.
+                    middle_name:
+                      type: string
+                      description: The user's middle name.
+                    last_name:
+                      type: string
+                      description: The user's last name.
+                    maiden_name:
+                      type: string
+                      description: The user's maiden name.
+                    nickname:
+                      type: string
+                      description: The user's nickname.
+                    city:
+                      type: string
+                      description: The user's city.
+                    state:
+                      type: string
+                      description: The user's state or region.
+                    country:
+                      type: string
+                      description: The user's country.
+                    address:
+                      type: string
+                      description: The user's street address.
+                    zip:
+                      type: string
+                      description: The user's ZIP or postal code.
+                    keywords:
+                      type: array
+                      description: The user's directory keywords.
+                      items:
+                        type: string
+
   /bart:
     get:
       summary: Get BART entries by type

+ 8 - 0
server/http/helpers_test.go

@@ -174,6 +174,7 @@ type chatSessionRetrieverAllSessionsParams []struct {
 }
 
 type directoryManagerParams struct {
+	aimDirectoryEntriesParams
 	categoriesParams
 	createCategoryParams
 	createKeywordParams
@@ -182,6 +183,13 @@ type directoryManagerParams struct {
 	keywordsByCategoryParams
 }
 
+// aimDirectoryEntriesParams is the list of parameters passed at the mock
+// DirectoryManager.AIMDirectoryEntries call site
+type aimDirectoryEntriesParams []struct {
+	result []state.AIMDirectoryEntry
+	err    error
+}
+
 // categoriesParams is the list of parameters passed at the mock
 // DirectoryManager.Categories call site
 type categoriesParams []struct {

+ 42 - 0
server/http/mgmt_api.go

@@ -135,6 +135,11 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		deleteDirectoryKeywordHandler(w, r, directoryManager, logger)
 	})
 
+	// Handlers for '/directory/aim' route
+	mux.HandleFunc("GET /directory/aim", func(w http.ResponseWriter, r *http.Request) {
+		getDirectoryAIMHandler(w, r, directoryManager, logger)
+	})
+
 	// Handlers for '/bart' route
 	mux.HandleFunc("GET /bart", func(w http.ResponseWriter, r *http.Request) {
 		getBARTByTypeHandler(w, r, bartAssetManager, logger)
@@ -1080,6 +1085,43 @@ func deleteDirectoryKeywordHandler(w http.ResponseWriter, r *http.Request, manag
 	w.WriteHeader(http.StatusNoContent)
 }
 
+// getDirectoryAIMHandler handles the GET /directory/aim endpoint.
+func getDirectoryAIMHandler(w http.ResponseWriter, r *http.Request, manager DirectoryManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+	entries, err := manager.AIMDirectoryEntries(r.Context())
+	if err != nil {
+		logger.Error("error in GET /directory/aim", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	out := make([]aimDirectoryEntryHandle, len(entries))
+	for i, entry := range entries {
+		keywords := entry.Keywords
+		if keywords == nil {
+			keywords = []string{}
+		}
+		out[i] = aimDirectoryEntryHandle{
+			ScreenName: entry.DisplayScreenName.String(),
+			FirstName:  entry.Info.FirstName,
+			MiddleName: entry.Info.MiddleName,
+			LastName:   entry.Info.LastName,
+			MaidenName: entry.Info.MaidenName,
+			Nickname:   entry.Info.NickName,
+			City:       entry.Info.City,
+			State:      entry.Info.State,
+			Country:    entry.Info.Country,
+			Address:    entry.Info.Address,
+			ZIP:        entry.Info.ZIPCode,
+			Keywords:   keywords,
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(out); err != nil {
+		errorMsg(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
 // getICQProfileHandler handles the GET /user/{screenname}/icq endpoint.
 func getICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfileManager, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")

+ 97 - 0
server/http/mgmt_api_test.go

@@ -2137,6 +2137,103 @@ func TestDirectoryCategoryHandler_GET(t *testing.T) {
 	}
 }
 
+func TestDirectoryAIMHandler_GET(t *testing.T) {
+	tt := []struct {
+		name       string
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "no entries",
+			want:       `[]`,
+			statusCode: http.StatusOK,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					aimDirectoryEntriesParams: aimDirectoryEntriesParams{
+						{
+							result: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "error fetching entries",
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					aimDirectoryEntriesParams: aimDirectoryEntriesParams{
+						{
+							result: nil,
+							err:    errors.New("error fetching entries"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "fetch some entries",
+			want:       `[{"screen_name":"Big Al","first_name":"Al","middle_name":"B","last_name":"Smith","maiden_name":"Jones","nickname":"Bigs","city":"Reno","state":"NV","country":"USA","address":"1 Main St","zip":"89501","keywords":["jazz","chess"]},{"screen_name":"Sue","first_name":"","middle_name":"","last_name":"","maiden_name":"","nickname":"","city":"","state":"","country":"","address":"","zip":"","keywords":[]}]`,
+			statusCode: http.StatusOK,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					aimDirectoryEntriesParams: aimDirectoryEntriesParams{
+						{
+							result: []state.AIMDirectoryEntry{
+								{
+									DisplayScreenName: "Big Al",
+									Info: state.AIMNameAndAddr{
+										FirstName:  "Al",
+										MiddleName: "B",
+										LastName:   "Smith",
+										MaidenName: "Jones",
+										NickName:   "Bigs",
+										City:       "Reno",
+										State:      "NV",
+										Country:    "USA",
+										Address:    "1 Main St",
+										ZIPCode:    "89501",
+									},
+									Keywords: []string{"jazz", "chess"},
+								},
+								{
+									DisplayScreenName: "Sue",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			responseRecorder := httptest.NewRecorder()
+			request := httptest.NewRequest(http.MethodGet, "/directory/aim", nil)
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.aimDirectoryEntriesParams {
+				directoryManager.EXPECT().
+					AIMDirectoryEntries(matchContext()).
+					Return(params.result, params.err)
+			}
+
+			getDirectoryAIMHandler(responseRecorder, request, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
 func TestDirectoryCategoryKeywordHandler_GET(t *testing.T) {
 	tt := []struct {
 		name       string

+ 62 - 0
server/http/mock_directory_manager_test.go

@@ -47,6 +47,68 @@ func (_m *mockDirectoryManager) EXPECT() *mockDirectoryManager_Expecter {
 	return &mockDirectoryManager_Expecter{mock: &_m.Mock}
 }
 
+// AIMDirectoryEntries provides a mock function for the type mockDirectoryManager
+func (_mock *mockDirectoryManager) AIMDirectoryEntries(ctx context.Context) ([]state.AIMDirectoryEntry, error) {
+	ret := _mock.Called(ctx)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AIMDirectoryEntries")
+	}
+
+	var r0 []state.AIMDirectoryEntry
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context) ([]state.AIMDirectoryEntry, error)); ok {
+		return returnFunc(ctx)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context) []state.AIMDirectoryEntry); ok {
+		r0 = returnFunc(ctx)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.AIMDirectoryEntry)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
+		r1 = returnFunc(ctx)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockDirectoryManager_AIMDirectoryEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AIMDirectoryEntries'
+type mockDirectoryManager_AIMDirectoryEntries_Call struct {
+	*mock.Call
+}
+
+// AIMDirectoryEntries is a helper method to define mock.On call
+//   - ctx context.Context
+func (_e *mockDirectoryManager_Expecter) AIMDirectoryEntries(ctx any) *mockDirectoryManager_AIMDirectoryEntries_Call {
+	return &mockDirectoryManager_AIMDirectoryEntries_Call{Call: _e.mock.On("AIMDirectoryEntries", ctx)}
+}
+
+func (_c *mockDirectoryManager_AIMDirectoryEntries_Call) Run(run func(ctx context.Context)) *mockDirectoryManager_AIMDirectoryEntries_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		run(
+			arg0,
+		)
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_AIMDirectoryEntries_Call) Return(aIMDirectoryEntrys []state.AIMDirectoryEntry, err error) *mockDirectoryManager_AIMDirectoryEntries_Call {
+	_c.Call.Return(aIMDirectoryEntrys, err)
+	return _c
+}
+
+func (_c *mockDirectoryManager_AIMDirectoryEntries_Call) RunAndReturn(run func(ctx context.Context) ([]state.AIMDirectoryEntry, error)) *mockDirectoryManager_AIMDirectoryEntries_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // Categories provides a mock function for the type mockDirectoryManager
 func (_mock *mockDirectoryManager) Categories(ctx context.Context) ([]state.Category, error) {
 	ret := _mock.Called(ctx)

+ 19 - 0
server/http/types.go

@@ -83,6 +83,10 @@ type ChatSessionRetriever interface {
 // DirectoryManager defines methods for managing interest categories and keywords
 // used in user profiles and directory listings.
 type DirectoryManager interface {
+	// AIMDirectoryEntries returns the user directory listing of every user
+	// who has directory info or keywords set.
+	AIMDirectoryEntries(ctx context.Context) ([]state.AIMDirectoryEntry, error)
+
 	// Categories returns all existing directory categories.
 	Categories(ctx context.Context) ([]state.Category, error)
 
@@ -296,6 +300,21 @@ type directoryCategory struct {
 	Name string `json:"name"`
 }
 
+type aimDirectoryEntryHandle struct {
+	ScreenName string   `json:"screen_name"`
+	FirstName  string   `json:"first_name"`
+	MiddleName string   `json:"middle_name"`
+	LastName   string   `json:"last_name"`
+	MaidenName string   `json:"maiden_name"`
+	Nickname   string   `json:"nickname"`
+	City       string   `json:"city"`
+	State      string   `json:"state"`
+	Country    string   `json:"country"`
+	Address    string   `json:"address"`
+	ZIP        string   `json:"zip"`
+	Keywords   []string `json:"keywords"`
+}
+
 type directoryCategoryCreate struct {
 	Name string `json:"name"`
 }

+ 10 - 0
state/user.go

@@ -247,6 +247,16 @@ type AIMNameAndAddr struct {
 	Address string
 }
 
+// AIMDirectoryEntry is a user's listing in the user directory.
+type AIMDirectoryEntry struct {
+	// DisplayScreenName is the user's display screen name.
+	DisplayScreenName DisplayScreenName
+	// Info is the user's name and address information.
+	Info AIMNameAndAddr
+	// Keywords are the user's directory keywords, in slot order.
+	Keywords []string
+}
+
 // ICQBasicInfo holds basic information about an ICQ user, including their name, contact details, and location.
 type ICQBasicInfo struct {
 	// Address is the user's residential address.

+ 91 - 0
state/user_store.go

@@ -281,6 +281,97 @@ func (f SQLiteUserStore) FindByAIMNameAndAddr(ctx context.Context, info AIMNameA
 	return users, nil
 }
 
+// AIMDirectoryEntries returns every user that has user directory info or
+// keywords set, ordered by screen name.
+func (f SQLiteUserStore) AIMDirectoryEntries(ctx context.Context) ([]AIMDirectoryEntry, error) {
+	q := `
+		SELECT
+			u.displayScreenName,
+			u.aim_firstName,
+			u.aim_lastName,
+			u.aim_middleName,
+			u.aim_maidenName,
+			u.aim_country,
+			u.aim_state,
+			u.aim_city,
+			u.aim_nickName,
+			u.aim_zipCode,
+			u.aim_address,
+			k1.name,
+			k2.name,
+			k3.name,
+			k4.name,
+			k5.name
+		FROM users u
+		LEFT JOIN aimKeyword k1 ON k1.id = u.aim_keyword1
+		LEFT JOIN aimKeyword k2 ON k2.id = u.aim_keyword2
+		LEFT JOIN aimKeyword k3 ON k3.id = u.aim_keyword3
+		LEFT JOIN aimKeyword k4 ON k4.id = u.aim_keyword4
+		LEFT JOIN aimKeyword k5 ON k5.id = u.aim_keyword5
+		WHERE u.aim_firstName <> ''
+		   OR u.aim_lastName <> ''
+		   OR u.aim_middleName <> ''
+		   OR u.aim_maidenName <> ''
+		   OR u.aim_country <> ''
+		   OR u.aim_state <> ''
+		   OR u.aim_city <> ''
+		   OR u.aim_nickName <> ''
+		   OR u.aim_zipCode <> ''
+		   OR u.aim_address <> ''
+		   OR u.aim_keyword1 IS NOT NULL
+		   OR u.aim_keyword2 IS NOT NULL
+		   OR u.aim_keyword3 IS NOT NULL
+		   OR u.aim_keyword4 IS NOT NULL
+		   OR u.aim_keyword5 IS NOT NULL
+		ORDER BY u.identScreenName
+	`
+
+	rows, err := f.db.QueryContext(ctx, q)
+	if err != nil {
+		return nil, fmt.Errorf("AIMDirectoryEntries: %w", err)
+	}
+	defer rows.Close()
+
+	var entries []AIMDirectoryEntry
+	for rows.Next() {
+		var entry AIMDirectoryEntry
+		var keywords [5]sql.NullString
+		err := rows.Scan(
+			&entry.DisplayScreenName,
+			&entry.Info.FirstName,
+			&entry.Info.LastName,
+			&entry.Info.MiddleName,
+			&entry.Info.MaidenName,
+			&entry.Info.Country,
+			&entry.Info.State,
+			&entry.Info.City,
+			&entry.Info.NickName,
+			&entry.Info.ZIPCode,
+			&entry.Info.Address,
+			&keywords[0],
+			&keywords[1],
+			&keywords[2],
+			&keywords[3],
+			&keywords[4],
+		)
+		if err != nil {
+			return nil, fmt.Errorf("AIMDirectoryEntries: %w", err)
+		}
+		for _, k := range keywords {
+			if k.Valid {
+				entry.Keywords = append(entry.Keywords, k.String)
+			}
+		}
+		entries = append(entries, entry)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("AIMDirectoryEntries: %w", err)
+	}
+
+	return entries, nil
+}
+
 // icqInterestsWhereClause builds SQL and args for matching a category code and
 // keyword(s) against the four stored interest slots (same semantics as
 // FindByICQInterests).

+ 62 - 0
state/user_store_test.go

@@ -3037,6 +3037,68 @@ func TestSQLiteUserStore_SetDirectoryInfo(t *testing.T) {
 	})
 }
 
+func TestSQLiteUserStore_AIMDirectoryEntries(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	category, err := f.CreateCategory(context.Background(), "Hobbies")
+	assert.NoError(t, err)
+	for _, name := range []string{"Biking", "Chess"} {
+		_, err := f.CreateKeyword(context.Background(), name, category.ID)
+		assert.NoError(t, err)
+	}
+
+	for _, name := range []string{"alice", "bob", "carol", "dave"} {
+		err := f.InsertUser(context.Background(), User{
+			IdentScreenName:   NewIdentScreenName(name),
+			DisplayScreenName: DisplayScreenName(name),
+		})
+		assert.NoError(t, err)
+	}
+
+	aliceInfo := AIMNameAndAddr{
+		FirstName:  "Alice",
+		LastName:   "Doe",
+		MiddleName: "Marie",
+		MaidenName: "Smith",
+		Country:    "USA",
+		State:      "CA",
+		City:       "San Francisco",
+		NickName:   "Ali",
+		ZIPCode:    "94105",
+		Address:    "123 Main St",
+	}
+	bobInfo := AIMNameAndAddr{City: "Austin"}
+
+	alice := NewIdentScreenName("alice")
+	assert.NoError(t, f.SetDirectoryInfo(context.Background(), alice, aliceInfo))
+	assert.NoError(t, f.SetKeywords(context.Background(), alice, [5]string{"Biking", "Chess"}))
+	assert.NoError(t, f.SetDirectoryInfo(context.Background(), NewIdentScreenName("bob"), bobInfo))
+	assert.NoError(t, f.SetKeywords(context.Background(), NewIdentScreenName("carol"), [5]string{"Chess"}))
+
+	entries, err := f.AIMDirectoryEntries(context.Background())
+	assert.NoError(t, err)
+	assert.Equal(t, []AIMDirectoryEntry{
+		{DisplayScreenName: "alice", Info: aliceInfo, Keywords: []string{"Biking", "Chess"}},
+		{DisplayScreenName: "bob", Info: bobInfo},
+		{DisplayScreenName: "carol", Keywords: []string{"Chess"}},
+	}, entries)
+
+	assert.NoError(t, f.SetDirectoryInfo(context.Background(), alice, AIMNameAndAddr{}))
+	assert.NoError(t, f.SetKeywords(context.Background(), alice, [5]string{}))
+
+	entries, err = f.AIMDirectoryEntries(context.Background())
+	assert.NoError(t, err)
+	assert.Equal(t, []AIMDirectoryEntry{
+		{DisplayScreenName: "bob", Info: bobInfo},
+		{DisplayScreenName: "carol", Keywords: []string{"Chess"}},
+	}, entries)
+}
+
 func TestSQLiteUserStore_Categories(t *testing.T) {
 	t.Run("Retrieve Keyword Categories Successfully", func(t *testing.T) {
 		defer func() {