feat(pwa): add web share target for text and images with offline queue
This commit is contained in:
1 parent
c181ffea54
commit
86d2cd0138
9 files changed
+360
-2
No files matched your search
@@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
### Added
|
### Added
|
||||||
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
|
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
|
||||||
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
|
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
|
||||||
|
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
|
||||||
|
|
||||||
## [0.10.0] - 2026-10-30
|
## [0.10.0] - 2026-10-30
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -7,8 +7,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/health` | Lebenszeichen |
|
| GET | `/health` | Lebenszeichen |
|
||||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) |
|
||||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||||
|
| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
|
||||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
||||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||||
|
|||||||
@@ -47,6 +47,22 @@
|
|||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "media",
|
||||||
|
"accept": ["image/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
"name": "Gedanke sofort erfassen",
|
"name": "Gedanke sofort erfassen",
|
||||||
|
|||||||
+73
-1
@@ -5,10 +5,12 @@
|
|||||||
// - Icons and manifest: from the cache, refreshed in the background.
|
// - Icons and manifest: from the cache, refreshed in the background.
|
||||||
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
|
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
|
||||||
// Raise VERSION when this file changes in a way that needs fresh caches.
|
// Raise VERSION when this file changes in a way that needs fresh caches.
|
||||||
const VERSION = 'v2';
|
const VERSION = 'v3';
|
||||||
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
||||||
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
||||||
const MAX_ASSETS = 80;
|
const MAX_ASSETS = 80;
|
||||||
|
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||||
|
const SHARE_STORE_NAME = 'shared_items';
|
||||||
const SHELL_URLS = [
|
const SHELL_URLS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -80,8 +82,78 @@ async function staleWhileRevalidate(request) {
|
|||||||
return hit || refresh;
|
return hit || refresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openShareDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(SHARE_DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||||
|
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePendingShare(item) {
|
||||||
|
const db = await openShareDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(SHARE_STORE_NAME).add(item);
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(tx.error);
|
||||||
|
};
|
||||||
|
tx.onabort = () => {
|
||||||
|
db.close();
|
||||||
|
reject(tx.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShareTarget(event) {
|
||||||
|
try {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const title = formData.get('title');
|
||||||
|
const text = formData.get('text');
|
||||||
|
const url = formData.get('url');
|
||||||
|
const media = formData.get('media');
|
||||||
|
|
||||||
|
let fileName = '';
|
||||||
|
let hasMedia = false;
|
||||||
|
if (media && typeof media === 'object') {
|
||||||
|
fileName = media.name || 'image';
|
||||||
|
hasMedia = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePendingShare({
|
||||||
|
title: typeof title === 'string' ? title.trim() : '',
|
||||||
|
text: typeof text === 'string' ? text.trim() : '',
|
||||||
|
url: typeof url === 'string' ? url.trim() : '',
|
||||||
|
fileName,
|
||||||
|
hasMedia,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SW] Share-Target Fehler:', err);
|
||||||
|
}
|
||||||
|
return Response.redirect('/?shared=pending', 303);
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin === self.location.origin && url.pathname === '/share-target') {
|
||||||
|
event.respondWith(handleShareTarget(event));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { UserAvatar } from './components/UserAvatar';
|
|||||||
import { CommandPalette } from './components/CommandPalette';
|
import { CommandPalette } from './components/CommandPalette';
|
||||||
import { AppFooter } from './components/AppFooter';
|
import { AppFooter } from './components/AppFooter';
|
||||||
import { AboutModal } from './components/AboutModal';
|
import { AboutModal } from './components/AboutModal';
|
||||||
|
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
@@ -555,6 +556,46 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||||
|
|
||||||
|
// Web Share Target: import shared thoughts from external apps
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const hasPendingShare = params.get('shared') === 'pending';
|
||||||
|
|
||||||
|
const processShares = async () => {
|
||||||
|
try {
|
||||||
|
const shares = await consumePendingShares();
|
||||||
|
if (shares.length === 0) return;
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
for (const share of shares) {
|
||||||
|
const content = formatSharedThought(share);
|
||||||
|
if (content) {
|
||||||
|
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
|
||||||
|
importedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedCount > 0) {
|
||||||
|
triggerHaptic(15);
|
||||||
|
setDemoNotice('Erfolgreich im Quick Dump abgelegt');
|
||||||
|
setTimeout(() => setDemoNotice(null), 4000);
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Import geteilter Daten:', err);
|
||||||
|
} finally {
|
||||||
|
if (hasPendingShare) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('shared');
|
||||||
|
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processShares();
|
||||||
|
}, [user, refreshAll]);
|
||||||
|
|
||||||
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
||||||
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
||||||
const handleDemoReset = useCallback(() => {
|
const handleDemoReset = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatSharedThought } from '../shareTarget';
|
||||||
|
|
||||||
|
describe('formatSharedThought', () => {
|
||||||
|
it('formats pure text correctly', () => {
|
||||||
|
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats pure url correctly', () => {
|
||||||
|
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines title and url when text is empty', () => {
|
||||||
|
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
|
||||||
|
.toBe('Toller Artikel\nhttps://example.com/article');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate URL when text already contains it', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Artikel',
|
||||||
|
text: 'Lies das hier: https://example.com/article unbedingt!',
|
||||||
|
url: 'https://example.com/article',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate URL when text is exactly the URL', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'News',
|
||||||
|
text: 'https://news.example.com',
|
||||||
|
url: 'https://news.example.com',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('News\nhttps://news.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate title if text starts with title', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'MindSift Update',
|
||||||
|
text: 'MindSift Update ist jetzt verfügbar.',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('MindSift Update ist jetzt verfügbar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines title, text and distinct URL', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Empfehlung',
|
||||||
|
text: 'Ein großartiges Tool für schnelles Erfassen.',
|
||||||
|
url: 'https://mindsift.app',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image with text', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Foto vom Ausflug',
|
||||||
|
fileName: 'berge.jpg',
|
||||||
|
hasMedia: true,
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image only with filename', () => {
|
||||||
|
const input = {
|
||||||
|
fileName: 'screenshot.png',
|
||||||
|
hasMedia: true,
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image only without filename', () => {
|
||||||
|
const input = {
|
||||||
|
hasMedia: true,
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string if nothing is provided', () => {
|
||||||
|
expect(formatSharedThought({})).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
export interface SharedData {
|
||||||
|
id?: number;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
fileName?: string;
|
||||||
|
hasMedia?: boolean;
|
||||||
|
createdAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||||
|
const SHARE_STORE_NAME = 'shared_items';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines title, text, url and media note into a single thought string.
|
||||||
|
* Ensures URLs are not duplicated if already present in the shared text or title.
|
||||||
|
*/
|
||||||
|
export function formatSharedThought(share: SharedData): string {
|
||||||
|
const title = (share.title || '').trim();
|
||||||
|
const text = (share.text || '').trim();
|
||||||
|
const url = (share.url || '').trim();
|
||||||
|
const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)';
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
// Check if url is already contained in text or title
|
||||||
|
const urlInText = url !== '' && text.includes(url);
|
||||||
|
const urlInTitle = url !== '' && title.includes(url);
|
||||||
|
|
||||||
|
// Combine title and text
|
||||||
|
if (title && text) {
|
||||||
|
if (text === title) {
|
||||||
|
content = text;
|
||||||
|
} else if (text.startsWith(title)) {
|
||||||
|
content = text;
|
||||||
|
} else {
|
||||||
|
content = `${title}\n${text}`;
|
||||||
|
}
|
||||||
|
} else if (title) {
|
||||||
|
content = title;
|
||||||
|
} else if (text) {
|
||||||
|
content = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append URL if not already included in text or title
|
||||||
|
if (url && !urlInText && !urlInTitle) {
|
||||||
|
if (content) {
|
||||||
|
content = `${content}\n${url}`;
|
||||||
|
} else {
|
||||||
|
content = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media note handling
|
||||||
|
if (share.hasMedia || share.fileName) {
|
||||||
|
if (content) {
|
||||||
|
content = `${content}\n${mediaNote}`;
|
||||||
|
} else if (share.fileName) {
|
||||||
|
content = `[${share.fileName}] ${mediaNote}`;
|
||||||
|
} else {
|
||||||
|
content = mediaNote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker.
|
||||||
|
*/
|
||||||
|
export async function consumePendingShares(): Promise<SharedData[]> {
|
||||||
|
if (typeof indexedDB === 'undefined') return [];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const req = indexedDB.open(SHARE_DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||||
|
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||||
|
db.close();
|
||||||
|
return resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(SHARE_STORE_NAME);
|
||||||
|
const getAllReq = store.getAll();
|
||||||
|
|
||||||
|
getAllReq.onsuccess = () => {
|
||||||
|
const items: SharedData[] = getAllReq.result || [];
|
||||||
|
store.clear();
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve(items);
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
resolve(items);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getAllReq.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
req.onerror = () => resolve([]);
|
||||||
|
} catch {
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -308,6 +308,11 @@ func (s *Server) Router() http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Web Share Target fallback when service worker is not active yet: redirect to /
|
||||||
|
r.Post("/share-target", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
// Embedded Frontend SPA Handler
|
// Embedded Frontend SPA Handler
|
||||||
if s.staticFS != nil {
|
if s.staticFS != nil {
|
||||||
fileServer := http.FileServer(http.FS(s.staticFS))
|
fileServer := http.FileServer(http.FS(s.staticFS))
|
||||||
|
|||||||
@@ -1533,6 +1533,24 @@ func TestPasswordResetFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShareTargetFallbackRedirects(t *testing.T) {
|
||||||
|
store, err := storage.NewStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
router := NewServer(&config.Config{Port: "8080", DatabasePath: ":memory:", JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil).Router()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/share-target", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected 303 See Other, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||||
|
t.Fatalf("expected Location '/', got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
|
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
|
||||||
func testSession(t *testing.T, store *storage.Store, userID string) string {
|
func testSession(t *testing.T, store *storage.Store, userID string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
Reference in new issue
Block a user