Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e65c3b413 | ||
|
|
87c978774f | ||
|
|
70a9f1d4cd |
@@ -24,6 +24,12 @@ Feature: Connect and manage video accounts
|
|||||||
Then a discovery pass for my account is triggered right away
|
Then a discovery pass for my account is triggered right away
|
||||||
And I do not have to wait for the next scheduled pass to see my videos
|
And I do not have to wait for the next scheduled pass to see my videos
|
||||||
|
|
||||||
|
Scenario: Connecting summarizes my newest videos right away
|
||||||
|
Given I have no connected video accounts
|
||||||
|
When I connect my YouTube account
|
||||||
|
Then up to the onboarding cap of my newest videos are summarized through the rate gate
|
||||||
|
And the rest are left to the scheduled recency-bounded pass
|
||||||
|
|
||||||
Scenario: Tokens are never stored in the clear
|
Scenario: Tokens are never stored in the clear
|
||||||
When I connect any video account
|
When I connect any video account
|
||||||
Then no OAuth token value is stored in the database
|
Then no OAuth token value is stored in the database
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
Feature: Paste a YouTube URL to summarize any video
|
||||||
|
As a user
|
||||||
|
I want to paste a YouTube link and get a summary
|
||||||
|
So that I can pull the specific video I want now, even from channels I don't follow
|
||||||
|
|
||||||
|
Scenario: Paste a valid YouTube URL
|
||||||
|
Given I am connected
|
||||||
|
When I paste a valid YouTube video URL
|
||||||
|
Then the video is added to my feed scoped to me
|
||||||
|
And it is queued for summarization through the shared rate gate
|
||||||
|
|
||||||
|
Scenario: Pasting an invalid link is rejected
|
||||||
|
When I paste something that is not a YouTube video URL
|
||||||
|
Then I get a clear error and nothing is added
|
||||||
|
|
||||||
|
Scenario: Pasting a video that cannot be found is honest
|
||||||
|
When I paste a URL whose video cannot be found
|
||||||
|
Then I am told it couldn't be found and nothing is added
|
||||||
|
|
||||||
|
Scenario: Pasting the same video twice does not duplicate it
|
||||||
|
Given I have pasted a video
|
||||||
|
When I paste the same video again
|
||||||
|
Then my feed still has exactly one entry for it
|
||||||
|
|
||||||
|
@pending
|
||||||
|
# Covered by the engine's ADR-010 no-transcript terminal state (degrade-never-error);
|
||||||
|
# there is no paste-specific test for it.
|
||||||
|
Scenario: A pasted video with no captions resolves honestly
|
||||||
|
When I paste a video that has no captions
|
||||||
|
Then it resolves to the "no transcript available" terminal state
|
||||||
@@ -211,19 +211,17 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
rows := f.apply(allRows)
|
rows := f.apply(allRows)
|
||||||
buckets := bucketRows(rows, a.recencyCutoff())
|
buckets := bucketRows(rows, a.recencyCutoff())
|
||||||
|
|
||||||
// hasConnected drives the empty state: a fresh account with a connection but
|
// hasConnected drives both the paste box (shown to ANY connected user, #2) and
|
||||||
// no discovery pass yet has zero rows, and we want it to read "connected,
|
// the empty-state copy (a fresh account with a connection but no discovery pass
|
||||||
// summaries land gradually" rather than "nothing here". Only needed when the
|
// yet reads "connected, summaries land gradually" rather than "nothing here").
|
||||||
// list is empty.
|
// Computed every render — not only when empty — so a user with videos still
|
||||||
hasConnected := false
|
// gets the paste box.
|
||||||
if buckets.empty() {
|
|
||||||
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "connections for user", err)
|
a.serverError(w, r, "connections for user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hasConnected = len(conns) > 0
|
hasConnected := len(conns) > 0
|
||||||
}
|
|
||||||
|
|
||||||
if isHTMX(r) {
|
if isHTMX(r) {
|
||||||
a.render(w, r, summaryList(buckets, hasConnected))
|
a.render(w, r, summaryList(buckets, hasConnected))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -111,3 +112,23 @@ func TestPasteDedupNoDuplicate(t *testing.T) {
|
|||||||
userID).Scan(&count))
|
userID).Scan(&count))
|
||||||
require.Equal(t, 1, count, "pasting the same video twice must not duplicate the row")
|
require.Equal(t, 1, count, "pasting the same video twice must not duplicate the row")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListShowsPasteFormForConnectedUserWithVideos(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
app.Fetcher = &fakeFetcher{title: "x"}
|
||||||
|
p := rawPool(t)
|
||||||
|
|
||||||
|
// Connected user with a non-empty feed (the case the bug missed: hasConnected
|
||||||
|
// was only computed for an empty feed).
|
||||||
|
_, err := p.Exec(context.Background(),
|
||||||
|
`INSERT INTO video_connections (user_id, provider, token_ref, status)
|
||||||
|
VALUES ($1, 'youtube', 'youtube/x/refresh_token', 'active')`, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
seedVideo(t, p, "11111111-1111-1111-1111-111111111111", "A talk", "https://youtu.be/aaaaaaaaaaa", time.Now())
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Contains(t, body(t, rec), `action="/paste"`,
|
||||||
|
"a connected user must see the paste box even when the feed has videos")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseYouTubeVideoID(t *testing.T) {
|
func TestParseYouTubeVideoID(t *testing.T) {
|
||||||
const id = "dQw4w9WgXcQ"
|
const id = "dQw4w9WgXcQ"
|
||||||
@@ -55,3 +60,21 @@ func TestParseYouTubeVideoID(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) {
|
||||||
|
render := func(connected bool) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected).Render(context.Background(), &buf); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
html := render(true)
|
||||||
|
if !strings.Contains(html, `name="url"`) || !strings.Contains(html, `action="/paste"`) {
|
||||||
|
t.Errorf("connected feed must show the paste form")
|
||||||
|
}
|
||||||
|
if strings.Contains(render(false), `name="url"`) {
|
||||||
|
t.Errorf("disconnected feed must not show the paste form")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ type flashView struct {
|
|||||||
// flashMessages maps each flash code to its banner. An unknown code renders no
|
// flashMessages maps each flash code to its banner. An unknown code renders no
|
||||||
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
||||||
var flashMessages = map[string]flashView{
|
var flashMessages = map[string]flashView{
|
||||||
flashConnected: {"success", "YouTube account connected."},
|
flashConnected: {"success", "YouTube account connected — finding your subscriptions. Your newest videos will appear below as they're summarized."},
|
||||||
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
||||||
flashDisconnected: {"success", "Account disconnected."},
|
flashDisconnected: {"success", "Account disconnected."},
|
||||||
flashDeleted: {"success", "Your account and all its data were deleted."},
|
flashDeleted: {"success", "Your account and all its data were deleted."},
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ templ flashBanner(code string) {
|
|||||||
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool) {
|
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool) {
|
||||||
@Layout("Tapir — Summaries") {
|
@Layout("Tapir — Summaries") {
|
||||||
@flashBanner(flash)
|
@flashBanner(flash)
|
||||||
|
if hasConnected {
|
||||||
|
@pasteForm()
|
||||||
|
}
|
||||||
if !b.empty() || f.active() {
|
if !b.empty() || f.active() {
|
||||||
@filterForm(f)
|
@filterForm(f)
|
||||||
}
|
}
|
||||||
@@ -143,6 +146,28 @@ templ pipelineBar(s PipelineStats) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pasteForm lets a connected user summarize any YouTube video by pasting its URL
|
||||||
|
// (Feature 2). The result (a video card, or an inline error) swaps into
|
||||||
|
// #paste-result; the next list refresh shows it inline. Summarization runs
|
||||||
|
// through the shared caption rate gate like every other fetch.
|
||||||
|
templ pasteForm() {
|
||||||
|
<form
|
||||||
|
class="paste"
|
||||||
|
method="post"
|
||||||
|
action="/paste"
|
||||||
|
hx-post="/paste"
|
||||||
|
hx-target="#paste-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Summarize any video
|
||||||
|
<input type="url" name="url" placeholder="Paste a YouTube link…" required/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
<div id="paste-result"></div>
|
||||||
|
}
|
||||||
|
|
||||||
templ filterForm(f Filter) {
|
templ filterForm(f Filter) {
|
||||||
<form
|
<form
|
||||||
class="filters"
|
class="filters"
|
||||||
|
|||||||
+599
-556
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,14 @@ var scenarioCoverage = map[string]string{
|
|||||||
// connect_account.feature
|
// connect_account.feature
|
||||||
"Connect a YouTube account": "TestCallbackExchangesAndRecordsConnection",
|
"Connect a YouTube account": "TestCallbackExchangesAndRecordsConnection",
|
||||||
"Connecting an account discovers videos immediately": "TestCallbackTriggersDiscovery",
|
"Connecting an account discovers videos immediately": "TestCallbackTriggersDiscovery",
|
||||||
|
"Connecting summarizes my newest videos right away": "TestNewestUnsummarizedVideoIDs",
|
||||||
"Tokens are never stored in the clear": "TestCallbackExchangesAndRecordsConnection",
|
"Tokens are never stored in the clear": "TestCallbackExchangesAndRecordsConnection",
|
||||||
|
|
||||||
|
// paste_url.feature
|
||||||
|
"Paste a valid YouTube URL": "TestPasteValidURLAddsAndRequests",
|
||||||
|
"Pasting an invalid link is rejected": "TestPasteInvalidURLRejected",
|
||||||
|
"Pasting a video that cannot be found is honest": "TestPasteVideoNotFound",
|
||||||
|
"Pasting the same video twice does not duplicate it": "TestPasteDedupNoDuplicate",
|
||||||
"Revoking a connection stops watching but keeps history": "TestDisconnectRemovesTokenAndConnectionKeepsAccount",
|
"Revoking a connection stops watching but keeps history": "TestDisconnectRemovesTokenAndConnectionKeepsAccount",
|
||||||
|
|
||||||
// summarize_mode.feature
|
// summarize_mode.feature
|
||||||
|
|||||||
Reference in New Issue
Block a user