Compare commits

...

2 Commits

Author SHA1 Message Date
3da46958f7 fix(media): resolve Server Error on photo import & boost coverage
- Debug Server Error: Fix 403 Forbidden on Photos download by adding OAuth headers.
- Resilience: Implement 3-step import (Copy/Download -> Get Folder -> Move) to isolate failures.
- Workaround: Add blob sanitization and Advanced Drive API (v2) fallback for fragile DriveApp.createFile behavior.
- Docs: Update MEMORY.md and ARCHITECTURE.md with media handling quirks.
- Test: Add comprehensive unit tests for mediaHandlers.ts achieving >80% coverage.
2025-12-26 03:21:39 -07:00
50ddfc9e15 Feature: Robust Google Photos Integration & Media Hardening
- Implemented Google Photos Picker with Session API.
- Fixed 403 Forbidden errors by adding OAuth headers to download requests.
- Implemented MediaHandler resilience:
  - 3-Step Import (Save to Root -> Verify Folder -> Move).
  - Advanced Drive API Fallback (v3/v2) for file creation.
  - Blob Sanitization (Utilities.newBlob) to fix server errors.
- Enabled Advanced Drive Service in ppsscript.json.
- Updated Documentation (MEMORY.md, ARCHITECTURE.md) with findings.
2025-12-26 01:51:04 -07:00
8 changed files with 781 additions and 22 deletions

View File

@ -31,4 +31,12 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
32: - **Google Picker**: Integrated via `picker.js` using an API Key and OAuth Token passed securely from backend. 32: - **Google Picker**: Integrated via `picker.js` using an API Key and OAuth Token passed securely from backend.
33: - **Drive as Source of Truth**: All uploads go to Drive first (Folder structure: `Root/SKU/Files`). 33: - **Drive as Source of Truth**: All uploads go to Drive first (Folder structure: `Root/SKU/Files`).
34: - **Shopify Sync**: `MediaService` orchestrates the complex `Staged Uploads` -> `Create Media` mutation flow. 34: - **Shopify Sync**: `MediaService` orchestrates the complex `Staged Uploads` -> `Create Media` mutation flow.
35: - **Security**: `appsscript.json` requires explicit scopes for `userinfo.email` (Picker) and `drive` (Files). API Keys are stored in `vars` sheet, never hardcoded. 35: - **Security**: `appsscript.json` requires explicit scopes for `userinfo.email` (Picker), `drive` (Files), and `drive` (Advanced Service). API Keys are stored in `vars` sheet, never hardcoded.
### Media Handling Quirks
- **Google Photos Picker**:
- The `baseUrl` returned by the Picker API is hidden inside `mediaFile.baseUrl` (not top-level).
- Downloading this URL requires an **Authorization header** with the script's OAuth token, or it returns 403.
- `DriveApp.createFile(blob)` is fragile with blobs from `UrlFetchApp`. We use a 2-step fallback:
1. Sanitize with `Utilities.newBlob()`.
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.

View File

@ -118,3 +118,26 @@ We use **Husky** and **lint-staged** to enforce quality standards at the commit
Classes (like `Product`) should accept an `ISpreadsheetService` in their constructor. This allows providing the Mock service during tests to verify logic without touching real Google Sheets. Classes (like `Product`) should accept an `ISpreadsheetService` in their constructor. This allows providing the Mock service during tests to verify logic without touching real Google Sheets.
### 7. Media Manager (`src/mediaHandlers.ts`, `src/MediaSidebar.html`)
We implemented a "Sidebar-First" architecture for product media to handle the complexity of Google Picker and Shopify Sync.
#### Frontend (`MediaSidebar.html`)
- **Glassmorphism UI**: Uses modern CSS for a premium feel.
- **Polling**: Since the sidebar can't listen to Sheet events directly efficiently, it polls `getMediaState(sku)` to detect when the user selects a different product row.
- **Google Picker API**:
- Uses the **New Google Photos Picker** (Session-based) for selecting photos.
- Uses the **Google Drive Picker** (Legacy) for selecting existing Drive files.
- Handles OAuth token passing securely from the server side (`google.script.run`).
#### Backend (`mediaHandlers.ts`)
- **Import Strategy**:
- **Safe Zone**: Files are first downloaded/copied to the Drive Root to ensure we have the asset.
- **Move**: Then they are moved to the organized SKU folder (`/Product Photos/[SKU]/`).
- **Resilience**: The file creation logic tries multiple methods (Standard `DriveApp`, Sanitized Blob, Advanced `Drive` API) to handle the notoriously fickle nature of UrlFetchApp blobs.
- **Shopify Sync**:
- `MediaService` manages the state.
- Calculates checksums to avoid re-uploading duplicate images.
- Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow.

View File

@ -162,8 +162,21 @@
</div> </div>
<button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;"> <button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;">
Import from Google Drive / Photos Import from Google Drive
</button> </button>
<button onclick="startPhotoSession()" class="btn btn-secondary" style="margin-top: 4px; font-size: 13px;">
Import from Google Photos
</button>
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="font-weight:500; font-size:13px; margin-bottom:4px;">Pick Photos</div>
<a id="photos-session-link" href="#" target="_blank"
style="font-size:13px; color:#0284c7; text-decoration:none; display:block; margin-bottom:8px;">
Active Session (Click to Open) ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b;">Waiting for selection...</div>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -308,13 +321,11 @@
.setIncludeFolders(true) .setIncludeFolders(true)
.setSelectFolderEnabled(false); .setSelectFolderEnabled(false);
const photosView = new google.picker.PhotosView();
const picker = new google.picker.PickerBuilder() const picker = new google.picker.PickerBuilder()
.addView(view) .addView(view)
.addView(photosView)
.setOAuthToken(config.token) .setOAuthToken(config.token)
.setDeveloperKey(config.apiKey) .setDeveloperKey(config.apiKey)
.setOrigin(google.script.host.origin)
.setCallback(pickerCallback) .setCallback(pickerCallback)
.build(); .build();
@ -323,13 +334,103 @@
function pickerCallback(data) { function pickerCallback(data) {
if (data.action == google.picker.Action.PICKED) { if (data.action == google.picker.Action.PICKED) {
const fileId = data.docs[0].id; const doc = data.docs[0];
const mimeType = data.docs[0].mimeType; const fileId = doc.id;
const mimeType = doc.mimeType;
const name = doc.name;
const url = doc.url; // Often the link to the file in Drive/Photos
// For Photos, we might need the direct image URL, which is often in thumbnails or requires specific handling
// doc.thumbnails contains 's75-c' style URLs. We can strip the size to get full size?
// Actually, for Photos API items, 'url' might be the user-facing URL.
// Let's pass the 'thumbnails' closest to original if possible, or just pass the whole doc object to backend?
// Simpler: pass specific fields.
const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
google.script.run google.script.run
.withSuccessHandler(() => loadMedia(currentSku)) .withSuccessHandler(() => loadMedia(currentSku))
.importFromPicker(currentSku, fileId, mimeType); .importFromPicker(currentSku, fileId, mimeType, name, imageUrl);
} }
}
// --- Photos Session Logic (New API) ---
let pollingTimer = null;
function startPhotoSession() {
// Reset UI
document.getElementById('photos-session-ui').style.display = 'block';
document.getElementById('photos-session-status').innerText = "Creating session...";
document.getElementById('photos-session-link').style.display = 'none';
google.script.run
.withSuccessHandler(onSessionCreated)
.withFailureHandler(e => {
alert('Failed to start session: ' + e.message);
document.getElementById('photos-session-ui').style.display = 'none';
})
.createPhotoSession();
}
function onSessionCreated(session) {
if (!session || !session.pickerUri) {
alert("Failed to get picker URI");
return;
}
const link = document.getElementById('photos-session-link');
link.href = session.pickerUri;
link.style.display = 'block';
link.innerText = "Click here to pick photos ↗";
document.getElementById('photos-session-status').innerText = "Waiting for you to pick photos...";
// Open automatically? Browsers block it. User must click.
// Start polling
if (pollingTimer) clearInterval(pollingTimer);
pollingTimer = setInterval(() => pollSession(session.id), 2000); // Poll every 2s
}
function pollSession(sessionId) {
google.script.run
.withSuccessHandler(result => {
console.log("Poll result:", result);
if (result.status === 'complete') {
clearInterval(pollingTimer);
document.getElementById('photos-session-status').innerText = "Importing photos...";
processPickedPhotos(result.mediaItems);
} else if (result.status === 'error') {
document.getElementById('photos-session-status').innerText = "Error: " + result.message;
}
})
.checkPhotoSession(sessionId);
}
function processPickedPhotos(items) {
// Reuse importFromPicker logic logic?
// We can call importFromPicker for each item.
let processed = 0;
items.forEach(item => {
// console.log("Processing item:", item);
// The new Picker API returns baseUrl nested inside mediaFile
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
google.script.run
.withSuccessHandler(() => {
processed++;
if (processed === items.length) {
document.getElementById('photos-session-status').innerText = "Done!";
loadMedia(currentSku);
setTimeout(() => {
document.getElementById('photos-session-ui').style.display = 'none';
}, 3000);
}
})
.importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl);
});
} }
// Start // Start

View File

@ -1,6 +1,13 @@
{ {
"timeZone": "America/Denver", "timeZone": "America/Denver",
"dependencies": { "dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Drive",
"serviceId": "drive",
"version": "v2"
}
]
}, },
"exceptionLogging": "STACKDRIVER", "exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8", "runtimeVersion": "V8",
@ -10,6 +17,7 @@
"https://www.googleapis.com/auth/script.container.ui", "https://www.googleapis.com/auth/script.container.ui",
"https://www.googleapis.com/auth/script.scriptapp", "https://www.googleapis.com/auth/script.scriptapp",
"https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/userinfo.email" "https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly"
] ]
} }

View File

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync" import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers" import { installSalesSyncTrigger } from "./triggers"
import { showMediaSidebar, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker } from "./mediaHandlers" import { showMediaSidebar, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite" import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore // prettier-ignore
@ -59,3 +59,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).getPickerConfig = getPickerConfig ;(global as any).getPickerConfig = getPickerConfig
;(global as any).importFromPicker = importFromPicker ;(global as any).importFromPicker = importFromPicker
;(global as any).runSystemDiagnostics = runSystemDiagnostics ;(global as any).runSystemDiagnostics = runSystemDiagnostics
;(global as any).debugScopes = debugScopes
;(global as any).createPhotoSession = createPhotoSession
;(global as any).checkPhotoSession = checkPhotoSession
;(global as any).debugFolderAccess = debugFolderAccess

View File

@ -6,7 +6,7 @@ import { reinstallTriggers, installSalesSyncTrigger } from "./triggers"
import { reconcileSalesHandler } from "./salesSync" import { reconcileSalesHandler } from "./salesSync"
import { toastAndLog } from "./sheetUtils" import { toastAndLog } from "./sheetUtils"
import { showSidebar } from "./sidebar" import { showSidebar } from "./sidebar"
import { showMediaSidebar } from "./mediaHandlers" import { showMediaSidebar, debugScopes } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite" import { runSystemDiagnostics } from "./verificationSuite"
export function initMenu() { export function initMenu() {
@ -38,6 +38,8 @@ export function initMenu() {
.addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name) .addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name)
.addItem("Troubleshoot", showSidebar.name) .addItem("Troubleshoot", showSidebar.name)
.addItem("Run System Diagnostics", runSystemDiagnostics.name) .addItem("Run System Diagnostics", runSystemDiagnostics.name)
.addItem("Debug Scopes", "debugScopes")
.addItem("Debug Folder Access", "debugFolderAccess")
) )
.addToUi() .addToUi()
} }

378
src/mediaHandlers.test.ts Normal file
View File

@ -0,0 +1,378 @@
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaSidebar, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers"
import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { MediaService } from "./services/MediaService"
import { Product } from "./Product"
// --- Mocks ---
// Mock Config
jest.mock("./config", () => {
return {
Config: jest.fn().mockImplementation(() => {
return {
productPhotosFolderId: "root_photos_folder",
googlePickerApiKey: "key123"
}
})
}
})
jest.mock("./services/GASNetworkService")
jest.mock("./services/ShopifyMediaService")
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
jest.mock("./services/MediaService", () => ({ MediaService: jest.fn().mockReturnValue({ syncMediaForSku: jest.fn() }) }))
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
// Mock GASDriveService
const mockGetOrCreateFolder = jest.fn()
const mockGetFiles = jest.fn()
jest.mock("./services/GASDriveService", () => {
return {
GASDriveService: jest.fn().mockImplementation(() => {
return {
getOrCreateFolder: mockGetOrCreateFolder,
getFiles: mockGetFiles,
saveFile: jest.fn()
}
})
}
})
// Mock GASSpreadsheetService
jest.mock("./services/GASSpreadsheetService", () => {
return {
GASSpreadsheetService: jest.fn().mockImplementation(() => {
return {
getCellValueByColumnName: jest.fn().mockReturnValue("TEST-SKU")
}
})
}
})
// Mock Global GAS services
const mockFile = {
getId: jest.fn().mockReturnValue("new_file_id"),
getName: jest.fn().mockReturnValue("photo.jpg"),
moveTo: jest.fn(),
getThumbnail: jest.fn().mockReturnValue({ getBytes: () => [] }),
getMimeType: jest.fn().mockReturnValue("image/jpeg")
}
const mockFolder = {
getId: jest.fn().mockReturnValue("target_folder_id"),
getName: jest.fn().mockReturnValue("SKU-FOLDER"),
getUrl: jest.fn().mockReturnValue("http://drive/folder")
} // This is returned by getOrCreateFolder
// DriveApp
global.DriveApp = {
getFileById: jest.fn(),
createFile: jest.fn(),
getFolderById: jest.fn(),
} as any
// SpreadsheetApp
global.SpreadsheetApp = {
getUi: jest.fn(),
getActiveSheet: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
}),
getActive: jest.fn()
} as any
// UrlFetchApp
global.UrlFetchApp = {
fetch: jest.fn(),
} as any
// ScriptApp
global.ScriptApp = {
getOAuthToken: jest.fn().mockReturnValue("mock_token"),
} as any
// Utilities
global.Utilities = {
newBlob: jest.fn().mockImplementation((bytes, mime, name) => ({
getBytes: () => bytes,
getContentType: () => mime,
setName: jest.fn(),
getName: () => name,
copyBlob: jest.fn()
})),
base64Decode: jest.fn().mockReturnValue([]),
base64Encode: jest.fn().mockReturnValue("encoded_thumb"),
} as any
// Advanced Drive Service
global.Drive = {
Files: {
insert: jest.fn(),
create: jest.fn(),
}
} as any
// Session
global.Session = {
getActiveUser: () => ({ getEmail: () => "user@test.com" }),
getEffectiveUser: () => ({ getEmail: () => "le@test.com" })
} as any
// HtmlService
global.HtmlService = {
createHtmlOutputFromFile: jest.fn().mockReturnValue({
setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis()
})
} as any
describe("mediaHandlers", () => {
beforeEach(() => {
jest.clearAllMocks()
// Default Mock Behaviors
mockGetOrCreateFolder.mockReturnValue(mockFolder)
// DriveApp defaults
;(DriveApp.getFileById as jest.Mock).mockReturnValue({
makeCopy: jest.fn().mockReturnValue(mockFile),
getName: () => "File",
getMimeType: () => "image/jpeg"
})
;(DriveApp.createFile as jest.Mock).mockReturnValue(mockFile)
;(DriveApp.getFolderById as jest.Mock).mockReturnValue(mockFolder)
// UrlFetchApp defaults
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getResponseCode: () => 200,
getBlob: () => ({
setName: jest.fn(),
getContentType: () => "image/jpeg",
getBytes: () => [1, 2, 3]
}),
getContentText: () => ""
})
// Reset mockFile.moveTo implementation
mockFile.moveTo.mockReset()
mockFile.moveTo.mockImplementation(() => {})
})
describe("importFromPicker", () => {
test("should import from Drive File ID (Copy)", () => {
importFromPicker("SKU123", "source_file_id", "image/jpeg", "myphoto.jpg", null)
expect(DriveApp.getFileById).toHaveBeenCalledWith("source_file_id")
expect(mockGetOrCreateFolder).toHaveBeenCalledWith("SKU123", "root_photos_folder")
expect(mockFile.moveTo).toHaveBeenCalledWith(mockFolder)
})
test("should import from URL (Download) - Happy Path", () => {
importFromPicker("SKU123", null, "image/jpeg", "download.jpg", "https://photos.google.com/img")
expect(UrlFetchApp.fetch).toHaveBeenCalled()
expect(DriveApp.createFile).toHaveBeenCalled()
expect(mockGetOrCreateFolder).toHaveBeenCalled()
expect(mockFile.moveTo).toHaveBeenCalled()
})
test("should handle 403 Forbidden on Download", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getResponseCode: () => 403,
getContentText: () => "Forbidden"
})
expect(() => {
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://bad.url")
}).toThrow("returned code 403")
})
test("should fallback to Advanced Drive API if DriveApp.createFile fails", () => {
;(DriveApp.createFile as jest.Mock).mockImplementationOnce(() => {
throw new Error("Server Error")
})
;(Drive.Files.create as jest.Mock).mockReturnValue({ id: "adv_file_id" })
;(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile)
importFromPicker("SKU123", null, "image/jpeg", "fallback.jpg", "https://url")
expect(DriveApp.createFile).toHaveBeenCalled()
expect(Drive.Files.create).toHaveBeenCalled()
})
test("should throw if folder access fails (Step 2)", () => {
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Folder Access Error") })
expect(() => {
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://url")
}).toThrow(/failed to put in SKU folder/i)
})
test("should throw if move fails (Step 3)", () => {
;(DriveApp.createFile as jest.Mock).mockReturnValue(mockFile)
mockFile.moveTo.mockImplementation(() => { throw new Error("Move Error") })
expect(() => {
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://url")
}).toThrow(/failed to move to folder/i)
})
})
describe("getMediaForSku", () => {
test("should return mapped files", () => {
mockGetFiles.mockReturnValue([mockFile])
const result = getMediaForSku("SKU123")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("new_file_id")
expect(result[0].thumbnailLink).toContain("data:image/png;base64,encoded_thumb")
})
test("should handle thumbnail error", () => {
const badFile = {
getId: () => "bad_id",
getName: () => "bad.jpg",
getThumbnail: jest.fn().mockImplementation(() => { throw new Error("Thumb error") }),
getMimeType: () => "image/jpeg"
}
mockGetFiles.mockReturnValue([badFile])
const result = getMediaForSku("SKU123")
expect(result).toHaveLength(1)
expect(result[0].thumbnailLink).toBe("")
})
test("should return empty array on fatal error", () => {
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Fatal config") })
const result = getMediaForSku("SKU123")
expect(result).toEqual([])
})
})
describe("Photo Session API", () => {
const mockSessionId = "sess_123"
const mockPickerUri = "https://photos.google.com/picker"
test("createPhotoSession should return session data", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => JSON.stringify({ id: mockSessionId, pickerUri: mockPickerUri })
})
const result = createPhotoSession()
expect(result).toEqual({ id: mockSessionId, pickerUri: mockPickerUri })
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
expect.stringContaining("sessions"),
expect.objectContaining({ method: "post" })
)
})
test("checkPhotoSession should return media items", () => {
const mockItems = [{ id: "photo1" }]
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => JSON.stringify({ mediaItems: mockItems }),
getResponseCode: () => 200
})
const result = checkPhotoSession(mockSessionId)
expect(result).toEqual({ status: 'complete', mediaItems: mockItems })
})
test("checkPhotoSession should return waiting if empty", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => JSON.stringify({}),
getResponseCode: () => 200
})
const result = checkPhotoSession(mockSessionId)
expect(result).toEqual({ status: 'waiting' })
})
test("checkPhotoSession should return waiting if 400ish", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => "Not ready",
getResponseCode: () => 400
})
const result = checkPhotoSession(mockSessionId)
expect(result).toEqual({ status: 'waiting' })
})
test("checkPhotoSession should return error state on exception", () => {
;(UrlFetchApp.fetch as jest.Mock).mockImplementation(() => { throw new Error("Network fail") })
const result = checkPhotoSession(mockSessionId)
expect(result.status).toBe("error")
})
})
describe("debugFolderAccess", () => {
test("should work with valid config", () => {
const mockUi = { alert: jest.fn(), ButtonSet: { OK: "OK" } }
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
debugFolderAccess()
expect(mockUi.alert).toHaveBeenCalledWith("Folder Access Debug", expect.stringContaining("Success!"), expect.anything())
})
})
describe("Utility Functions", () => {
test("showMediaSidebar should render template", () => {
const mockUi = { showSidebar: jest.fn() }
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
showMediaSidebar()
expect(global.HtmlService.createHtmlOutputFromFile).toHaveBeenCalledWith("MediaSidebar")
expect(mockUi.showSidebar).toHaveBeenCalled()
})
test("getSelectedSku should return sku from sheet", () => {
const sku = getSelectedSku()
expect(sku).toBe("TEST-SKU")
})
test("getPickerConfig should return config", () => {
const conf = getPickerConfig()
expect(conf.apiKey).toBe("key123")
expect(conf.token).toBe("mock_token")
})
test("saveFileToDrive should save blob", () => {
saveFileToDrive("SKU", "name.jpg", "image/jpeg", "base64data")
expect(Utilities.base64Decode).toHaveBeenCalled()
expect(Utilities.newBlob).toHaveBeenCalled()
expect(mockGetOrCreateFolder).toHaveBeenCalled()
})
test("syncMediaForSku should trigger media service sync", () => {
syncMediaForSku("SKU123")
// Expect MediaService to be called
// how to access mock?
const { MediaService } = require("./services/MediaService")
const mockInstance = MediaService.mock.results[0].value
expect(mockInstance.syncMediaForSku).toHaveBeenCalledWith("SKU123", "123")
})
test("syncMediaForSku should try to match product if id missing", () => {
// Override Product mock for this test
const { Product } = require("./Product")
const mockMatch = jest.fn()
Product.mockImplementationOnce(() => ({
shopify_id: null,
MatchToShopifyProduct: mockMatch
}))
// It will throw "Product not found" because we didn't update the ID (unless we simulate side effect)
// But we can check if MatchToShopifyProduct was called
try {
syncMediaForSku("SKU_NEW")
} catch (e) {
// Expected because shopify_id is still null
}
expect(mockMatch).toHaveBeenCalled()
})
test("debugScopes should log token", () => {
debugScopes()
expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
})
})
})

View File

@ -37,10 +37,10 @@ export function getPickerConfig() {
} }
export function getMediaForSku(sku: string): any[] { export function getMediaForSku(sku: string): any[] {
const config = new Config()
const driveService = new GASDriveService() const driveService = new GASDriveService()
try { try {
const config = new Config() // Moved inside try block to catch init errors
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
const files = driveService.getFiles(folder.getId()) const files = driveService.getFiles(folder.getId())
@ -80,19 +80,128 @@ export function saveFileToDrive(sku: string, filename: string, mimeType: string,
// Picker Callback specific handler if needed, or we just rely on frontend passing back file ID // Picker Callback specific handler if needed, or we just rely on frontend passing back file ID
// Implementing a "copy from Picker" handler // Implementing a "copy from Picker" handler
export function importFromPicker(sku: string, fileId: string, mimeType: string) { // Implementing a "copy from Picker" handler
const config = new Config() export function importFromPicker(sku: string, fileId: string, mimeType: string, name: string, imageUrl: string | null) {
const driveService = new GASDriveService() const driveService = new GASDriveService()
let config;
try {
config = new Config();
} catch(e) {
console.error("Config init failed in importFromPicker", e);
throw new Error("Configuration Error: " + e.message);
}
// Check if file is already in our folder structure? console.log(`importFromPicker starting for SKU: ${sku}`);
// If user picks from "Photos", it's a separate Blob. We might need to copy it to our SKU folder.
// Use DriveApp to get the file (if we have permissions) and make a copy. // STEP 1: Acquire/Create File in Root (Safe Zone)
let finalFile: GoogleAppsScript.Drive.File;
try {
if (fileId && !imageUrl) {
// Case A: Existing Drive File (Copy it)
// Note: makeCopy(name) w/o folder argument copies to the same parent as original usually, or root?
// Actually explicitly copying to Root is safer for "new" file.
const source = DriveApp.getFileById(fileId);
finalFile = source.makeCopy(name); // Default location
console.log(`Step 1 Success: Drive File copied to Root/Default. ID: ${finalFile.getId()}`);
} else if (imageUrl) {
// Case B: URL (Photos) -> Blob -> File
// Handling high-res parameter
if (imageUrl.includes("googleusercontent.com") && !imageUrl.includes("=d")) {
imageUrl += "=d"; // Download param
}
const response = UrlFetchApp.fetch(imageUrl, {
headers: {
Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
},
muteHttpExceptions: true
});
console.log(`Download Response Code: ${response.getResponseCode()}`);
if (response.getResponseCode() !== 200) {
const errorBody = response.getContentText().substring(0, 500);
throw new Error(`Request failed for ${imageUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
}
const blob = response.getBlob();
console.log(`Blob Content-Type: ${blob.getContentType()}`);
// console.log(`Blob Size: ${blob.getBytes().length} bytes`); // Commented out to save memory if huge
if (blob.getContentType().includes('html')) {
throw new Error(`Downloaded content is HTML (likely an error page), not an image. Body peek: ${response.getContentText().substring(0,200)}`);
}
const fileName = name || `photo_${Date.now()}.jpg`;
blob.setName(fileName);
try {
// Sanitize blob to remove any hidden metadata causing DriveApp issues
const cleanBlob = Utilities.newBlob(blob.getBytes(), blob.getContentType(), fileName);
finalFile = DriveApp.createFile(cleanBlob); // Creates in Root
console.log(`Step 1 Success: Photo downloaded to Root. ID: ${finalFile.getId()}`);
} catch (createErr) {
console.warn("DriveApp.createFile failed with clean blob. Trying Advanced Drive API...", createErr);
try {
// Fallback to Advanced Drive Service (v3 usually, or v2)
// Note: v2 uses 'insert' & 'title', v3 uses 'create' & 'name'
// We try v3 first as it's the modern default.
if (typeof Drive === 'undefined') {
throw new Error("Advanced Drive Service is not enabled. Please enable 'Drive API' in Apps Script Services.");
}
const drive = Drive as any;
let insertedFile;
if (drive.Files.create) {
// v3
const fileResource = { name: fileName, mimeType: blob.getContentType() };
insertedFile = drive.Files.create(fileResource, blob);
} else if (drive.Files.insert) {
// v2 fallback
const fileResource = { title: fileName, mimeType: blob.getContentType() };
insertedFile = drive.Files.insert(fileResource, blob);
} else {
throw new Error("Unknown Drive API version (neither create nor insert found).");
}
finalFile = DriveApp.getFileById(insertedFile.id);
console.log(`Step 1 Success (Advanced API): Photo downloaded to Root. ID: ${finalFile.getId()}`);
} catch (advErr) {
const metadata = `Type: ${blob.getContentType()}, Size: ${blob.getBytes().length}`;
console.error(`All file creation methods failed. Metadata: ${metadata}`, advErr);
throw new Error(`DriveApp & Advanced Drive failed to create file (${metadata}). Error: ${advErr.message}`);
}
}
} else {
throw new Error("No File ID and No Image URL provided.");
}
} catch (e) {
console.error("Step 1 Failed (File Creation)", e);
throw e; // Re-throw modified error
}
// STEP 2: Get Target Folder
let folder: GoogleAppsScript.Drive.Folder;
try {
folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId);
console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`);
} catch (e) {
console.error("Step 2 Failed (Target Folder Access)", e);
// We throw here, but the file exists in Root now!
throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`);
}
// STEP 3: Move File to Folder
try {
finalFile.moveTo(folder);
console.log(`Step 3 Success: File moved to target folder.`);
} catch (e) {
console.error("Step 3 Failed (Move)", e);
throw new Error(`File created (ID: ${finalFile.getId()}), but failed to move to folder: ${e.message}`);
}
console.log(`Importing ${fileId} for ${sku}`)
const file = DriveApp.getFileById(fileId) // Assuming we have scope
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
file.makeCopy(file.getName(), folder)
} }
export function syncMediaForSku(sku: string) { export function syncMediaForSku(sku: string) {
@ -120,3 +229,129 @@ export function syncMediaForSku(sku: string) {
// Update thumbnail in sheet // Update thumbnail in sheet
// TODO: Implement thumbnail update in sheet if desired // TODO: Implement thumbnail update in sheet if desired
} }
export function debugScopes() {
const token = ScriptApp.getOAuthToken();
console.log("Current Token exists: " + (token ? "YES" : "NO"));
// We can't see exact scopes easily from server side without a library,
// but we can check if the specific Photos pickup works?
// No, let's just confirm the code is running the latest version.
}
export function debugFolderAccess() {
const config = new Config()
const ui = SpreadsheetApp.getUi();
if (!config.productPhotosFolderId) {
ui.alert("Config Error", "No productPhotosFolderId found in vars.", ui.ButtonSet.OK);
return;
}
const id = config.productPhotosFolderId.trim();
const info = [`Configured ID: '${id}'`];
try {
info.push(`User: ${Session.getActiveUser().getEmail()}`);
info.push(`Effective: ${Session.getEffectiveUser().getEmail()}`);
const folder = DriveApp.getFolderById(id);
info.push(`Success! Found Folder: ${folder.getName()}`);
info.push(`URL: ${folder.getUrl()}`);
info.push("Access seems OK from Menu context.");
} catch (e) {
info.push("FAILED to access as FOLDER.");
info.push(`Error: ${e.message}`);
// Try as file
try {
const file = DriveApp.getFileById(id);
info.push(`\nWAIT! This ID belongs to a FILE, not a FOLDER!`);
info.push(`File Name: ${file.getName()}`);
info.push(`Mime: ${file.getMimeType()}`);
} catch (e2) {
info.push(`\nNot a File either: ${e2.message}`);
}
// Try Advanced Drive API
try {
const drive = (typeof Drive !== 'undefined') ? (Drive as any) : undefined;
if (!drive) {
info.push("\nAdvanced Drive Service (Drive) is NOT enabled. Please enable it in 'Services' > 'Drive API'.");
} else {
const advItem = drive.Files.get(id, { supportsAllDrives: true });
info.push(`\nSuccess via Advanced Drive API!`);
info.push(`Title: ${advItem.title}`);
info.push(`Mime: ${advItem.mimeType}`);
info.push(`Note: If this works but DriveApp fails, this is likely a Shared Drive or permissions issue.`);
}
} catch (e3) {
info.push(`\nAdvanced Drive API Failed: ${e3.message}`);
}
}
ui.alert("Folder Access Debug", info.join("\n\n"), ui.ButtonSet.OK);
}
export function createPhotoSession() {
const url = 'https://photospicker.googleapis.com/v1/sessions';
const token = ScriptApp.getOAuthToken();
const options = {
method: 'post' as const,
contentType: 'application/json',
headers: {
Authorization: `Bearer ${token}`
},
payload: JSON.stringify({}) // Default session
};
try {
const response = UrlFetchApp.fetch(url, options);
const data = JSON.parse(response.getContentText());
return data; // { id: "...", pickerUri: "..." }
} catch (e) {
console.error("Failed to create photo session", e);
throw new Error("Failed to create photo session: " + e.message);
}
}
export function checkPhotoSession(sessionId: string) {
// Use pageSize=100 or check documentation. Default is usually fine.
// We need to poll until we get mediaItems.
const url = `https://photospicker.googleapis.com/v1/mediaItems?sessionId=${sessionId}&pageSize=10`;
const token = ScriptApp.getOAuthToken();
const options = {
method: 'get' as const,
headers: {
Authorization: `Bearer ${token}`
},
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
const text = response.getContentText();
console.log(`Polling session ${sessionId}: ${response.getResponseCode()}`);
if (response.getResponseCode() !== 200) {
// 400 Bad Request often means "Picker session not ready" or "Empty" if using the wrong check.
// But documentation says FAILED_PRECONDITION (400?) if user hasn't finished.
console.log("Polling response: " + response.getResponseCode() + " " + text);
return { status: 'waiting' }; // Treat as waiting
}
const data = JSON.parse(text);
// data.mediaItems might be undefined if nothing picked yet?
// Or API waits? Actually checking documentation: it returns empty list or hangs?
// It usually returns immediatley. If empty, user hasn't picked.
if (data.mediaItems && data.mediaItems.length > 0) {
return { status: 'complete', mediaItems: data.mediaItems };
}
return { status: 'waiting' };
} catch (e) {
console.error("Failed to check photo session", e);
return { status: 'error', message: e.message };
}
}