diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 0d92a2c..cd95c83 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -39,7 +39,8 @@ The project follows a clean architecture pattern:
- `make run` - Run the app without Docker
- `make docker-run` - Run the app with Docker Compose
- `make build` - Build the binary
-- `make run-test-server` - Run the app with mock feed server for testing (uses test database)
+- `make run-test-server` - Run the app configured for UI tests (port 3001, uses `data/test-dev.db`)
+- `make run-feed-provider` - Run the standalone mock feed provider on port 3002
### Testing
@@ -107,11 +108,11 @@ Before running UI tests for the first time:
- UI tests are located in `tests/ui/` directory
- Use Page Object Model pattern (see `tests/ui/pages/`)
- Tests use Playwright with Chromium browser
-- **IMPORTANT**: Always use the backend testserver for feed testing instead of fetching feeds from the internet
- - Mock feeds are served from `http://localhost:3001/feeds/`
+- **IMPORTANT**: Always use the backend test server and mock feed provider instead of fetching feeds from the internet
+ - Mock feeds are served from `http://localhost:3002/feeds/`
- Available mock feeds are defined in `tests/ui/utils/helpers.js` as `MOCK_FEEDS`
- Never use real external feed URLs in tests to avoid network blockages
-- Test database is separate: `data/test-agg.db`
+- Test database is separate: `data/test-dev.db`
- Tests run sequentially to avoid database conflicts
### Naming Conventions
@@ -146,11 +147,11 @@ Before running UI tests for the first time:
**CRITICAL**: When testing features that involve RSS/Atom feeds:
1. **Always use the mock feed server** instead of real internet feeds
-2. Start the test server: `make run-test-server`
-3. Use mock feed URLs from the testserver:
- - `http://localhost:3001/feeds/tech-news.xml` - RSS 2.0 feed with 3 tech articles
- - `http://localhost:3001/feeds/science-blog.xml` - Atom 1.0 feed with 3 science articles
- - `http://localhost:3001/feeds/empty.xml` - Empty RSS feed for edge cases
+2. Start the test services: `make run-test-server` (app on 3001) and `make run-feed-provider` (feeds on 3002)
+3. Use mock feed URLs from the feed provider:
+ - `http://localhost:3002/feeds/tech-news.xml` - RSS 2.0 feed with 3 tech articles
+ - `http://localhost:3002/feeds/science-blog.xml` - Atom 1.0 feed with 3 science articles
+ - `http://localhost:3002/feeds/empty.xml` - Empty RSS feed for edge cases
4. Mock feeds are located in `internal/testserver/fixtures/`
5. Add new mock feeds in fixtures directory and reference them in tests
@@ -179,25 +180,25 @@ Before running UI tests for the first time:
- **Migration support:** Legacy Lite Reader data can be migrated from `agg.db`
- **Default port:** Application runs on port 3000 by default
- **Data persistence:** Database file is stored in `data/agg.db`
-- **Test database:** UI tests use separate database at `data/test-agg.db`
+- **Test database:** UI tests use separate database at `data/test-dev.db`
## Mock Feed System
The project includes a mock feed server for reliable, offline testing:
### Mock Feed Server
-- **Location**: `internal/testserver/`
-- **Port**: 3001 (when started via `make run-test-server`)
+- **Location**: `internal/testserver/` with entrypoint `cmd/mockfeedprovider/`
+- **Port**: 3002 (when started via `make run-feed-provider`)
- **Purpose**: Serves mock RSS and Atom feeds locally for testing
- **Benefits**: No internet required, consistent results, faster tests
### Available Mock Feeds
- **tech-news.xml**: RSS 2.0 feed with 3 technology articles
- - URL: `http://localhost:3001/feeds/tech-news.xml`
+ - URL: `http://localhost:3002/feeds/tech-news.xml`
- **science-blog.xml**: Atom 1.0 feed with 3 science articles
- - URL: `http://localhost:3001/feeds/science-blog.xml`
+ - URL: `http://localhost:3002/feeds/science-blog.xml`
- **empty.xml**: Empty RSS feed for edge case testing
- - URL: `http://localhost:3001/feeds/empty.xml`
+ - URL: `http://localhost:3002/feeds/empty.xml`
### Adding New Mock Feeds
1. Create a new XML file in `internal/testserver/fixtures/`
diff --git a/.gitignore b/.gitignore
index fe295cd..96fa97e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
.idea
data/agg.db
data/test-agg.db
+data/test-dev.db
public/cache/*.gzip
public/cache/*.spc
public/cache/images/*
diff --git a/Makefile b/Makefile
index c3bef27..ad606f3 100644
--- a/Makefile
+++ b/Makefile
@@ -103,20 +103,25 @@ test-ui-setup:
.PHONY: run-test-server
run-test-server:
- @echo "Starting test server with mock feeds..."
- @HTTP_PORT=3000 DB_PATH=data/test-agg.db CGO_ENABLED=0 go run -mod=vendor ./cmd/testserver/main.go
+ @echo "Starting test server on port 3001 using data/test-dev.db..."
+ @HTTP_PORT=3001 DB_PATH=data/test-dev.db CGO_ENABLED=0 go run -mod=vendor ./cmd/main.go
+
+.PHONY: run-feed-provider
+run-feed-provider:
+ @echo "Starting mock feed provider on port 3002..."
+ @MOCK_FEED_PROVIDER_PORT=3002 CGO_ENABLED=0 go run -mod=vendor ./cmd/mockfeedprovider/main.go
.PHONY: test-ui
test-ui:
@echo "Running UI tests..."
@mkdir -p reports/playwright
- @TEST_DB_PATH=data/test-agg.db npm run test:ui
+ @TEST_DB_PATH=data/test-dev.db npm run test:ui
.PHONY: test-ui-headed
test-ui-headed:
@echo "Running UI tests in headed mode..."
@mkdir -p reports/playwright
- @TEST_DB_PATH=data/test-agg.db npm run test:ui:headed
+ @TEST_DB_PATH=data/test-dev.db npm run test:ui:headed
.PHONY: test-all
test-all: test test-ui
diff --git a/TEST.md b/TEST.md
index b930b68..79c17e2 100644
--- a/TEST.md
+++ b/TEST.md
@@ -92,7 +92,8 @@ make test-all
- **Duration**: UI tests typically complete in 2-3 minutes
- **Parallelization**: Tests run sequentially (workers: 1) to avoid database conflicts
- **Browser**: Tests use Chromium in headless mode by default
-- **Database**: Each test run uses a separate test database (`data/test-agg.db`)
+- **Database**: Each test run uses a separate test database (`data/test-dev.db`)
+- **Ports**: UI tests hit the Lite Reader test server on `http://localhost:3001` and the mock feed provider on `http://localhost:3002`
- **Retries**: In CI, tests retry up to 2 times on failure
### Test Reports
@@ -204,21 +205,23 @@ See the page object files in `tests/ui/pages/` for all available methods.
The mock feed system provides offline RSS/Atom feeds for testing without requiring internet access.
**Architecture**:
-1. **Mock Feed Server**: A Go HTTP server (`internal/testserver/feedserver.go`) that serves XML feeds
-2. **Feed Fixtures**: Static XML files in `internal/testserver/fixtures/`
-3. **Automatic Startup**: The server starts automatically when running UI tests (via `run-test-server`)
+1. **Lite Reader Test Server**: `cmd/testserver/` boots the application with the UI-test database on `http://localhost:3001`
+2. **Mock Feed Provider**: `cmd/mockfeedprovider/` is a dedicated binary that serves XML fixtures via `internal/testserver/feedserver.go`
+3. **Feed Fixtures**: Static XML files in `internal/testserver/fixtures/`
+4. **Automatic Startup**: Playwright launches both servers via `make run-test-server` and `make run-feed-provider`
**Ports**:
-- Main app: `localhost:3000`
-- Mock feed server: `localhost:3001`
+- Main app (manual/dev): `localhost:3000`
+- Lite Reader test server (UI tests): `localhost:3001`
+- Mock feed provider: `localhost:3002`
### Available Mock Feeds
| Feed Name | URL | Format | Items | Description |
|-----------|-----|--------|-------|-------------|
-| Tech News | `http://localhost:3001/feeds/tech-news.xml` | RSS 2.0 | 3 | Technology articles |
-| Science Blog | `http://localhost:3001/feeds/science-blog.xml` | Atom 1.0 | 3 | Science articles |
-| Empty Feed | `http://localhost:3001/feeds/empty.xml` | RSS 2.0 | 0 | Feed with no items |
+| Tech News | `http://localhost:3002/feeds/tech-news.xml` | RSS 2.0 | 3 | Technology articles |
+| Science Blog | `http://localhost:3002/feeds/science-blog.xml` | Atom 1.0 | 3 | Science articles |
+| Empty Feed | `http://localhost:3002/feeds/empty.xml` | RSS 2.0 | 0 | Feed with no items |
Access feeds in tests using:
@@ -242,7 +245,7 @@ cat > internal/testserver/fixtures/my-feed.xml << 'EOF'
My Feed
- http://localhost:3001/feeds/my-feed
+ http://localhost:3002/feeds/my-feed
My test feedItem 1
@@ -259,10 +262,10 @@ EOF
```javascript
export const MOCK_FEEDS = {
- techNews: 'http://localhost:3001/feeds/tech-news.xml',
- scienceBlog: 'http://localhost:3001/feeds/science-blog.xml',
- empty: 'http://localhost:3001/feeds/empty.xml',
- myFeed: 'http://localhost:3001/feeds/my-feed.xml', // Add this
+ techNews: 'http://localhost:3002/feeds/tech-news.xml',
+ scienceBlog: 'http://localhost:3002/feeds/science-blog.xml',
+ empty: 'http://localhost:3002/feeds/empty.xml',
+ myFeed: 'http://localhost:3002/feeds/my-feed.xml', // Add this
};
```
@@ -277,7 +280,7 @@ await mainPage.addFeed(MOCK_FEEDS.myFeed);
- Use valid RSS 2.0 or Atom 1.0 format
- Include required fields: `title`, `link`, `description`
- For items, include: `title`, `link`, `pubDate` (RSS) or `published` (Atom)
-- Use `localhost:3001` URLs to avoid external dependencies
+- Use `localhost:3002` URLs to avoid external dependencies
- Add realistic content for better test coverage
## CI/CD Integration
@@ -340,15 +343,15 @@ make test-ui-setup
#### Issue: Port already in use
-**Error**: `listen tcp :3000: bind: address already in use`
+**Error**: `listen tcp :3001: bind: address already in use`
**Solution**:
```bash
-# Find and kill process using port 3000
-lsof -ti:3000 | xargs kill -9
+# Find and kill process using port 3001
+lsof -ti:3001 | xargs kill -9
-# Or use a different port
-HTTP_PORT=3002 make run-test-server
+# Or use a different port for the test server
+HTTP_PORT=3005 make run-test-server
```
#### Issue: Test database locked
@@ -358,10 +361,10 @@ HTTP_PORT=3002 make run-test-server
**Solution**:
```bash
# Stop all running processes
-pkill -f testserver
+pkill -f cmd/testserver
# Remove test database
-rm data/test-agg.db
+rm data/test-dev.db
# Run tests again
make test-ui
diff --git a/TODO.md b/TODO.md
index 444eebc..4cff081 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,2 +1,2 @@
TODO
-- [ ] Fix: unread-all does not work for starred items
\ No newline at end of file
+- [ ] Bug: multi item actions do not work for starred and unread collections
\ No newline at end of file
diff --git a/UI-TESTING-SUMMARY.md b/UI-TESTING-SUMMARY.md
index 751e153..c7c225a 100644
--- a/UI-TESTING-SUMMARY.md
+++ b/UI-TESTING-SUMMARY.md
@@ -6,16 +6,16 @@ This implementation adds comprehensive automated UI testing infrastructure to th
## Key Components
-### 1. Mock RSS Feed Server (`internal/testserver/`)
+### 1. Mock RSS Feed Server (`cmd/mockfeedprovider/`)
-A Go-based HTTP server that serves mock RSS and Atom feeds for testing:
+A dedicated Go-based HTTP server that serves mock RSS and Atom feeds for testing:
-- **Location**: `internal/testserver/feedserver.go`
+- **Location**: `internal/testserver/feedserver.go` with entrypoint `cmd/mockfeedprovider/main.go`
- **Features**:
- - Serves feeds on `http://localhost:3001`
+ - Serves feeds on `http://localhost:3002`
- Embedded feed fixtures (no external files needed at runtime)
- Supports RSS 2.0 and Atom 1.0 formats
- - Automatically starts/stops with test runs
+ - Automatically starts/stops with test runs via `make run-feed-provider`
**Sample Feeds**:
- `tech-news.xml` - RSS 2.0 feed with 3 technology articles
@@ -24,14 +24,13 @@ A Go-based HTTP server that serves mock RSS and Atom feeds for testing:
### 2. Test Server Command (`cmd/testserver/`)
-A specialized command that starts both the application and mock feed server:
+A specialized command that starts the Lite Reader application for UI tests:
-- **Purpose**: Provides a complete test environment
+- **Purpose**: Provides an isolated app instance for Playwright
- **Usage**: `make run-test-server`
- **Features**:
- - Uses separate test database (`data/test-agg.db`)
- - Starts mock feeds on port 3001
- - Starts main app on port 3000
+ - Uses separate test database (`data/test-dev.db`)
+ - Starts the app on port 3001 (base URL for UI tests)
### 3. Playwright Test Framework
@@ -73,7 +72,8 @@ Clean, maintainable test code using the Page Object pattern:
make test-ui-setup # Install Playwright and dependencies (one-time)
make test-ui # Run all UI tests (headless)
make test-ui-headed # Run with visible browser (debugging)
-make run-test-server # Start test environment manually
+make run-test-server # Start Lite Reader test server manually (port 3001)
+make run-feed-provider # Start mock feed provider manually (port 3002)
make test-all # Run both unit and UI tests
```
@@ -147,7 +147,7 @@ Updated `.github/workflows/tests.yaml`:
- Reliable and fast
### 2. Isolated Test Environment
-- Separate test database (`data/test-agg.db`)
+- Separate test database (`data/test-dev.db`)
- No interference with development data
- Clean state for each test run
@@ -196,7 +196,7 @@ test('should do something', async ({ page }) => {
const mainPage = new MainPage(page);
// Perform actions
- await mainPage.addFeed('http://localhost:3001/feeds/tech-news.xml');
+ await mainPage.addFeed('http://localhost:3002/feeds/tech-news.xml');
// Assert expectations
const count = await mainPage.getItemsCount();
@@ -212,7 +212,7 @@ test('should do something', async ({ page }) => {
My Feed
- http://localhost:3001/feeds/my-feed
+ http://localhost:3002/feeds/my-feed
Test feedTest Item
@@ -227,9 +227,9 @@ test('should do something', async ({ page }) => {
2. Add to `tests/ui/utils/helpers.js`:
```javascript
export const MOCK_FEEDS = {
- techNews: 'http://localhost:3001/feeds/tech-news.xml',
- scienceBlog: 'http://localhost:3001/feeds/science-blog.xml',
- myFeed: 'http://localhost:3001/feeds/my-feed.xml', // Add this
+ techNews: 'http://localhost:3002/feeds/tech-news.xml',
+ scienceBlog: 'http://localhost:3002/feeds/science-blog.xml',
+ myFeed: 'http://localhost:3002/feeds/my-feed.xml', // Add this
};
```
diff --git a/cmd/mockfeedprovider/main.go b/cmd/mockfeedprovider/main.go
new file mode 100644
index 0000000..4e8c7e9
--- /dev/null
+++ b/cmd/mockfeedprovider/main.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "strconv"
+ "syscall"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/cubny/lite-reader/internal/testserver"
+)
+
+const defaultFeedProviderPort = 3002
+
+func main() {
+ log.SetOutput(os.Stdout)
+ log.SetLevel(log.DebugLevel)
+ log.SetFormatter(&log.JSONFormatter{})
+
+ port := defaultFeedProviderPort
+ if raw := os.Getenv("MOCK_FEED_PROVIDER_PORT"); raw != "" {
+ if parsed, err := strconv.Atoi(raw); err == nil {
+ port = parsed
+ } else {
+ log.Warnf("invalid MOCK_FEED_PROVIDER_PORT %q, falling back to %d", raw, defaultFeedProviderPort)
+ }
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ mockServer := testserver.NewMockFeedServer(port)
+ if err := mockServer.Start(); err != nil {
+ log.Fatalf("failed to start mock feed provider: %v", err)
+ }
+
+ log.Infof("Mock feed provider running on port %d", port)
+
+ <-ctx.Done()
+
+ if err := mockServer.Stop(); err != nil {
+ log.Errorf("failed to stop mock feed provider cleanly: %v", err)
+ }
+}
diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go
deleted file mode 100644
index 34bdeaf..0000000
--- a/cmd/testserver/main.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package main
-
-import (
- "context"
- "os"
- "time"
-
- log "github.com/sirupsen/logrus"
-
- "github.com/cubny/lite-reader/internal"
- "github.com/cubny/lite-reader/internal/testserver"
-)
-
-func main() {
- log.SetOutput(os.Stdout)
- log.SetLevel(log.DebugLevel)
- log.SetFormatter(&log.JSONFormatter{})
-
- ctx, cancel := context.WithCancel(context.Background())
-
- // Start mock feed server on port 3001
- mockServer := testserver.NewMockFeedServer(3001)
- if err := mockServer.Start(); err != nil {
- log.Fatalf("failed to start mock feed server: %v", err)
- }
-
- // Give the mock server time to start
- time.Sleep(500 * time.Millisecond)
- log.Info("Mock feed server started on port 3001")
-
- // Start main application
- runMigration := true
- app, err := internal.Init(ctx, runMigration)
- if err != nil {
- log.Fatalf("failed to initiate App, %v", err)
- }
-
- internal.WaitTermination()
- cancel()
-
- // Stop mock feed server
- if err = mockServer.Stop(); err != nil {
- log.Errorf("failed to stop mock feed server: %v", err)
- }
-
- // Stop main application
- if err = app.Stop(); err != nil {
- log.Errorf("failed to stop the app gracefully, %v", err)
- }
-}
diff --git a/internal/infra/http/api/errors.go b/internal/infra/http/api/errors.go
index 9d1dd47..cb364ad 100644
--- a/internal/infra/http/api/errors.go
+++ b/internal/infra/http/api/errors.go
@@ -81,3 +81,34 @@ func InvalidParams(w http.ResponseWriter, details string) error {
func NotFound(w http.ResponseWriter, details string) error {
return newJSONError(errNotFound, details).write(w, http.StatusNotFound)
}
+
+// HTMLError writes an HTML error response for HTMX requests
+func HTMLError(w http.ResponseWriter, htmlContent string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(htmlContent))
+}
+
+// HTMLBadRequest writes a bad request HTML error for HTMX requests
+func HTMLBadRequest(w http.ResponseWriter, htmlContent string) {
+ HTMLError(w, htmlContent)
+}
+
+// HTMLInternalError writes an internal server error HTML response for HTMX requests
+func HTMLInternalError(w http.ResponseWriter, htmlContent string) {
+ HTMLError(w, htmlContent)
+}
+
+// HTMLOK writes a successful HTML response for HTMX requests
+func HTMLOK(w http.ResponseWriter, htmlContent string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(htmlContent))
+}
+
+// HTMLCreated writes a created HTML response for HTMX requests
+func HTMLCreated(w http.ResponseWriter, htmlContent string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(htmlContent))
+}
diff --git a/internal/infra/http/api/feed.go b/internal/infra/http/api/feed.go
index 798d13f..2b0c7a9 100644
--- a/internal/infra/http/api/feed.go
+++ b/internal/infra/http/api/feed.go
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
+ "fmt"
"net/http"
"github.com/julienschmidt/httprouter"
@@ -26,18 +27,40 @@ import (
func (h *Router) addFeed(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
command, err := toAddFeedCommand(w, r, p)
if err != nil {
+ if isHTMXRequest(r) {
+ HTMLBadRequest(w, renderFeedError("Invalid feed URL"))
+ }
return
}
log.Infof("addFeed: command %v", command)
- // define t as a new uuid
t, err := h.feedService.AddFeed(command)
if err != nil {
log.WithError(err).Errorf("addFeed: service %s", err)
+ if isHTMXRequest(r) {
+ HTMLInternalError(w, renderFeedError("Failed to add feed"))
+ return
+ }
_ = InternalError(w, "failed to add feed due to server internal error")
return
}
+ // For HTMX requests, get all feeds and return the complete list
+ if isHTMXRequest(r) {
+ userID := r.Context().Value(cxutil.UserIDKey).(int)
+ feeds, err := h.feedService.ListFeeds(userID)
+ if err != nil {
+ log.WithError(err).Errorf("addFeed: listFeeds %s", err)
+ HTMLInternalError(w, renderFeedError("Failed to refresh feed list"))
+ return
+ }
+ // Emit HX-Trigger header with new feed ID for auto-fetch
+ w.Header().Set("HX-Trigger", fmt.Sprintf(`{"feedAdded": {"feedId": %d}}`, t.ID))
+ HTMLCreated(w, renderFeedList(feeds))
+ return
+ }
+
+ // JSON response for non-HTMX requests
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(toAddFeedResponse(t)); err != nil {
log.WithError(err).Errorf("setFeed: encoder %s", err)
@@ -50,10 +73,21 @@ func (h *Router) listFeeds(w http.ResponseWriter, r *http.Request, _ httprouter.
resp, err := h.feedService.ListFeeds(userID)
if err != nil {
log.WithError(err).Errorf("listFeeds: service %s", err)
+ if isHTMXRequest(r) {
+ HTMLInternalError(w, renderFeedError("Cannot list feeds"))
+ return
+ }
_ = InternalError(w, "cannot list feeds")
return
}
+ // For HTMX requests, return HTML fragment
+ if isHTMXRequest(r) {
+ HTMLOK(w, renderFeedList(resp))
+ return
+ }
+
+ // JSON response for non-HTMX requests
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(toListFeedResponse(resp)); err != nil {
log.WithError(err).Errorf("listFeeds: encoder %s", err)
@@ -115,7 +149,7 @@ func (h *Router) fetchFeedNewItems(w http.ResponseWriter, r *http.Request, p htt
}
w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(toGetItemsResponse(items)); err != nil {
+ if err := json.NewEncoder(w).Encode(toFeedItemsResponse(command.FeedID, items)); err != nil {
log.WithError(err).Errorf("fetchFeedNewItems: encoder %s", err)
_ = InternalError(w, "cannot encode response")
return
@@ -153,23 +187,51 @@ func (h *Router) unreadFeedItems(w http.ResponseWriter, r *http.Request, p httpr
func (h *Router) deleteFeed(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
command, err := toDeleteFeedCommand(w, r, p)
if err != nil {
+ if isHTMXRequest(r) {
+ HTMLBadRequest(w, renderFeedError("Invalid feed ID"))
+ }
return
}
cmdDeleteFeedItems, err := toDeleteFeedItemsCommand(w, r, p)
if err != nil {
+ if isHTMXRequest(r) {
+ HTMLBadRequest(w, renderFeedError("Invalid feed ID"))
+ }
return
}
if err := h.itemService.DeleteFeedItems(cmdDeleteFeedItems); err != nil {
+ if isHTMXRequest(r) {
+ HTMLInternalError(w, renderFeedError("Cannot delete feed"))
+ return
+ }
_ = InternalError(w, "cannot delete feed")
return
}
if err := h.feedService.DeleteFeed(command); err != nil {
+ if isHTMXRequest(r) {
+ HTMLInternalError(w, renderFeedError("Cannot delete feed"))
+ return
+ }
_ = InternalError(w, "cannot delete feed")
return
}
+ // For HTMX requests, return updated feed list
+ if isHTMXRequest(r) {
+ userID := r.Context().Value(cxutil.UserIDKey).(int)
+ feeds, err := h.feedService.ListFeeds(userID)
+ if err != nil {
+ log.WithError(err).Errorf("deleteFeed: listFeeds %s", err)
+ HTMLInternalError(w, renderFeedError("Failed to refresh feed list"))
+ return
+ }
+ HTMLOK(w, renderFeedList(feeds))
+ return
+ }
+
+ // JSON response for non-HTMX requests
w.WriteHeader(http.StatusNoContent)
}
diff --git a/internal/infra/http/api/feed_test.go b/internal/infra/http/api/feed_test.go
index 6b52075..bcfded8 100644
--- a/internal/infra/http/api/feed_test.go
+++ b/internal/infra/http/api/feed_test.go
@@ -155,12 +155,12 @@ func TestRouter_fetchFeedNewItems(t *testing.T) {
specs := []spec{
{
- Name: "ok",
- Method: http.MethodPut,
- Target: "/feeds/1/fetch",
- ExpectedStatus: http.StatusOK,
- ExpectedBody: `[{"id":1,"title":"title","dir":"dir","desc":"description","link":"link","is_new":false,"starred":true,"timestamp":"` +
- now.Format(time.RFC3339Nano) + `"}]`,
+ Name: "ok",
+ Method: http.MethodPut,
+ Target: "/feeds/1/fetch",
+ ExpectedStatus: http.StatusOK,
+ ExpectedBody: `{"feed_id":1,"items":[{"id":1,"title":"title","dir":"dir","desc":"description","link":"link","is_new":false,"starred":true,"timestamp":"` +
+ now.Format(time.RFC3339Nano) + `"}]}`,
MockFn: func(i *mocks.ItemService, f *mocks.FeedService, _ *mocks.AuthService) {
f.EXPECT().FetchItems(1).Return([]*item.Item{
{
diff --git a/internal/infra/http/api/fragments_feed.go b/internal/infra/http/api/fragments_feed.go
new file mode 100644
index 0000000..d35c8fe
--- /dev/null
+++ b/internal/infra/http/api/fragments_feed.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "fmt"
+ "html"
+ "strings"
+
+ "github.com/cubny/lite-reader/internal/app/feed"
+)
+
+// renderFeedRow generates an HTML fragment for a single feed row
+func renderFeedRow(f *feed.Feed) string {
+ return fmt.Sprintf(`
+
+
%s
+
%s
+
`,
+ f.ID,
+ f.ID,
+ html.EscapeString(f.Link),
+ html.EscapeString(f.Title),
+ formatUnreadCount(f.UnreadCount),
+ )
+}
+
+// renderFeedList generates an HTML fragment for the complete feed list
+func renderFeedList(feeds []*feed.Feed) string {
+ var builder strings.Builder
+
+ // Always include the Unread and Starred static feeds first
+ builder.WriteString(`
+
+
+
Unread
+
+
+
+
+
Starred
+
+`)
+
+ if len(feeds) == 0 {
+ // No user feeds, but we still have unread/starred
+ return builder.String()
+ }
+
+ // Add user feeds
+ for _, f := range feeds {
+ builder.WriteString(renderFeedRow(f))
+ builder.WriteString("\n")
+ }
+ return builder.String()
+}
+
+// renderFeedError returns an error message fragment
+func renderFeedError(msg string) string {
+ errorStyle := "padding: 10px; color: #d9534f; background: #f8d7da; " +
+ "border: 1px solid #f5c6cb; border-radius: 4px; margin: 10px;"
+ return fmt.Sprintf(`
+ %s
+
`, errorStyle, html.EscapeString(msg))
+}
+
+// formatUnreadCount formats the unread count for display
+func formatUnreadCount(count int) string {
+ if count > 0 {
+ return fmt.Sprintf("%d", count)
+ }
+ return ""
+}
diff --git a/internal/infra/http/api/login.go b/internal/infra/http/api/login.go
index fc60c31..ccd5acb 100644
--- a/internal/infra/http/api/login.go
+++ b/internal/infra/http/api/login.go
@@ -21,10 +21,11 @@ func (h *Router) login(w http.ResponseWriter, r *http.Request, _ httprouter.Para
response, err := h.authService.Login(command)
if err != nil {
if isHTMXRequest(r) {
- // Return HTML error for HTMX requests
- w.Header().Set("Content-Type", "text/html")
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`
Invalid email or password
`))
+ // Return HTML error for HTMX requests (200 OK so HTMX will swap it)
+ // w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // w.WriteHeader(http.StatusOK)
+ // _, _ = w.Write([]byte(`
Invalid email or password
`))
+ HTMLBadRequest(w, `
Invalid email or password
`)
} else {
_ = BadRequest(w, err.Error())
}
diff --git a/internal/infra/http/api/model.go b/internal/infra/http/api/model.go
index f846838..302238a 100644
--- a/internal/infra/http/api/model.go
+++ b/internal/infra/http/api/model.go
@@ -17,6 +17,11 @@ import (
"github.com/cubny/lite-reader/internal/infra/http/api/cxutil"
)
+const (
+ contentTypeFormURLEncoded = "application/x-www-form-urlencoded"
+ contentTypeMultipartForm = "multipart/form-data"
+)
+
type AddFeedRequest struct {
URL string `json:"url"`
}
@@ -94,12 +99,39 @@ func toGetItemsResponse(items []*item.Item) []*ItemResponse {
return resp
}
+// FeedItemsResponse wraps items with feed metadata
+type FeedItemsResponse struct {
+ FeedID int `json:"feed_id"`
+ Items []*ItemResponse `json:"items"`
+}
+
+func toFeedItemsResponse(feedID int, items []*item.Item) *FeedItemsResponse {
+ return &FeedItemsResponse{
+ FeedID: feedID,
+ Items: toGetItemsResponse(items),
+ }
+}
+
func toAddFeedCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (*feed.AddFeedCommand, error) {
request := &AddFeedRequest{}
- if err := json.NewDecoder(r.Body).Decode(request); err != nil {
- log.WithError(err).Errorf("toAddFeedCommand: decoder %s", err)
- _ = BadRequest(w, "cannot decode request body")
- return nil, err
+
+ // Check if this is a form submission (HTMX) or JSON request
+ contentType := r.Header.Get("Content-Type")
+ if isHTMXRequest(r) || contentType == contentTypeFormURLEncoded || contentType == contentTypeMultipartForm {
+ // Parse form data
+ if err := r.ParseForm(); err != nil {
+ log.WithError(err).Error("addFeed: failed to parse form data")
+ _ = BadRequest(w, "invalid request body")
+ return nil, err
+ }
+ request.URL = r.FormValue("url")
+ } else {
+ // Parse JSON
+ if err := json.NewDecoder(r.Body).Decode(request); err != nil {
+ log.WithError(err).Errorf("toAddFeedCommand: decoder %s", err)
+ _ = BadRequest(w, "cannot decode request body")
+ return nil, err
+ }
}
if err := request.Validate(); err != nil {
@@ -245,11 +277,17 @@ func toLoginCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
// Check if this is a form submission (HTMX) or JSON request
contentType := r.Header.Get("Content-Type")
- if isHTMXRequest(r) || contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data" {
+ if isHTMXRequest(r) || contentType == contentTypeFormURLEncoded || contentType == contentTypeMultipartForm {
// Parse form data
if err := r.ParseForm(); err != nil {
log.WithError(err).Error("login: failed to parse form data")
- _ = BadRequest(w, "invalid request body")
+ if isHTMXRequest(r) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`